@cardstack/boxel-cli 0.0.1
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/LICENSE +21 -0
- package/README.md +111 -0
- package/api.ts +24 -0
- package/dist/index.js +75 -0
- package/package.json +78 -0
- package/src/commands/profile.ts +457 -0
- package/src/commands/realm/create.ts +245 -0
- package/src/commands/realm/index.ts +16 -0
- package/src/commands/realm/pull.ts +245 -0
- package/src/commands/realm/push.ts +379 -0
- package/src/commands/realm/sync.ts +587 -0
- package/src/commands/run-command.ts +186 -0
- package/src/index.ts +47 -0
- package/src/lib/auth.ts +169 -0
- package/src/lib/boxel-cli-client.ts +631 -0
- package/src/lib/checkpoint-manager.ts +609 -0
- package/src/lib/colors.ts +9 -0
- package/src/lib/profile-manager.ts +583 -0
- package/src/lib/realm-sync-base.ts +647 -0
- package/src/lib/sync-logic.ts +169 -0
- package/src/lib/sync-manifest.ts +81 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { createRealm as coreCreateRealm } from '../commands/realm/create';
|
|
2
|
+
import { pull as realmPull } from '../commands/realm/pull';
|
|
3
|
+
import { getProfileManager, type ProfileManager } from './profile-manager';
|
|
4
|
+
|
|
5
|
+
const MIME = {
|
|
6
|
+
CardSource: 'application/vnd.card+source',
|
|
7
|
+
CardJson: 'application/vnd.card+json',
|
|
8
|
+
JSON: 'application/json',
|
|
9
|
+
JSONAPI: 'application/vnd.api+json',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
function ensureTrailingSlash(url: string): string {
|
|
13
|
+
return url.endsWith('/') ? url : `${url}/`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CreateRealmOptions {
|
|
17
|
+
/** URL slug for the realm (lowercase, numbers, hyphens). */
|
|
18
|
+
realmName: string;
|
|
19
|
+
/** Human-readable display name. */
|
|
20
|
+
displayName: string;
|
|
21
|
+
backgroundURL?: string;
|
|
22
|
+
iconURL?: string;
|
|
23
|
+
/** Wait for the realm to pass its readiness check (default: true). */
|
|
24
|
+
waitForReady?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CreateRealmResult {
|
|
28
|
+
realmUrl: string;
|
|
29
|
+
created: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PullOptions {
|
|
33
|
+
/** Delete local files that don't exist in the realm (default: false). */
|
|
34
|
+
delete?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PullResult {
|
|
38
|
+
/** Relative file paths that were downloaded. */
|
|
39
|
+
files: string[];
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReadResult {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
status?: number;
|
|
46
|
+
/** Parsed JSON document (for .json files). */
|
|
47
|
+
document?: Record<string, unknown>;
|
|
48
|
+
/** Raw text content (for non-JSON files like .gts). */
|
|
49
|
+
content?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface WriteResult {
|
|
54
|
+
ok: boolean;
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DeleteResult {
|
|
59
|
+
ok: boolean;
|
|
60
|
+
error?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SearchResult {
|
|
64
|
+
ok: boolean;
|
|
65
|
+
status?: number;
|
|
66
|
+
data?: Record<string, unknown>[];
|
|
67
|
+
error?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ListFilesResult {
|
|
71
|
+
filenames: string[];
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RunCommandResult {
|
|
76
|
+
status: 'ready' | 'error' | 'unusable';
|
|
77
|
+
/** Serialized command result (JSON string), or null. */
|
|
78
|
+
result?: string | null;
|
|
79
|
+
error?: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface LintMessage {
|
|
83
|
+
ruleId: string | null;
|
|
84
|
+
severity: 1 | 2;
|
|
85
|
+
message: string;
|
|
86
|
+
line: number;
|
|
87
|
+
column: number;
|
|
88
|
+
endLine?: number;
|
|
89
|
+
endColumn?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LintResult {
|
|
93
|
+
fixed: boolean;
|
|
94
|
+
output: string;
|
|
95
|
+
messages: LintMessage[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface WaitForReadyResult {
|
|
99
|
+
ready: boolean;
|
|
100
|
+
error?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface WaitForFileOptions {
|
|
104
|
+
timeoutMs?: number;
|
|
105
|
+
pollMs?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AtomicResult {
|
|
109
|
+
ok: boolean;
|
|
110
|
+
response?: unknown;
|
|
111
|
+
error?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CancelIndexingResult {
|
|
115
|
+
ok: boolean;
|
|
116
|
+
error?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class BoxelCLIClient {
|
|
120
|
+
private pm: ProfileManager;
|
|
121
|
+
|
|
122
|
+
constructor(pm?: ProfileManager) {
|
|
123
|
+
this.pm = pm ?? getProfileManager();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Ensure a boxel profile exists, migrating from env vars if needed.
|
|
128
|
+
* Call once at process startup (e.g. factory entrypoint) before any
|
|
129
|
+
* BoxelCLIClient operations.
|
|
130
|
+
*/
|
|
131
|
+
static async ensureProfile(opts?: {
|
|
132
|
+
realmServerUrl?: string;
|
|
133
|
+
}): Promise<void> {
|
|
134
|
+
if (opts?.realmServerUrl && !process.env.REALM_SERVER_URL) {
|
|
135
|
+
process.env.REALM_SERVER_URL = opts.realmServerUrl;
|
|
136
|
+
}
|
|
137
|
+
let pm = getProfileManager();
|
|
138
|
+
let result = await pm.migrateFromEnv();
|
|
139
|
+
if (result?.profileId) {
|
|
140
|
+
pm.switchProfile(result.profileId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Returns the active profile's identifying info, or null if no profile
|
|
146
|
+
* is active. Intended for callers that need to validate profile state.
|
|
147
|
+
*/
|
|
148
|
+
getActiveProfile(): { matrixId: string; realmServerUrl: string } | null {
|
|
149
|
+
let active = this.pm.getActiveProfile();
|
|
150
|
+
if (!active) return null;
|
|
151
|
+
return {
|
|
152
|
+
matrixId: active.id,
|
|
153
|
+
realmServerUrl: active.profile.realmServerUrl,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read a file from a realm. Returns parsed JSON for .json files,
|
|
159
|
+
* raw text for everything else (.gts, etc.).
|
|
160
|
+
*/
|
|
161
|
+
async read(realmUrl: string, path: string): Promise<ReadResult> {
|
|
162
|
+
let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
let response = await this.pm.authedRealmFetch(url, {
|
|
166
|
+
method: 'GET',
|
|
167
|
+
headers: { Accept: MIME.CardSource },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
let body = await response.text();
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
status: response.status,
|
|
175
|
+
error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let text = await response.text();
|
|
180
|
+
try {
|
|
181
|
+
let document = JSON.parse(text) as Record<string, unknown>;
|
|
182
|
+
return { ok: true, status: response.status, document };
|
|
183
|
+
} catch {
|
|
184
|
+
return { ok: true, status: response.status, content: text };
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: err instanceof Error ? err.message : String(err),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Write a file to a realm. Content is sent as-is with card+source MIME type.
|
|
196
|
+
* Path should include the file extension.
|
|
197
|
+
*/
|
|
198
|
+
async write(
|
|
199
|
+
realmUrl: string,
|
|
200
|
+
path: string,
|
|
201
|
+
content: string,
|
|
202
|
+
): Promise<WriteResult> {
|
|
203
|
+
let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
let response = await this.pm.authedRealmFetch(url, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: {
|
|
209
|
+
Accept: MIME.CardSource,
|
|
210
|
+
'Content-Type': MIME.CardSource,
|
|
211
|
+
},
|
|
212
|
+
body: content,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
let body = await response.text();
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { ok: true };
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: err instanceof Error ? err.message : String(err),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Delete a file from a realm.
|
|
234
|
+
*/
|
|
235
|
+
async delete(realmUrl: string, path: string): Promise<DeleteResult> {
|
|
236
|
+
let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
let response = await this.pm.authedRealmFetch(url, {
|
|
240
|
+
method: 'DELETE',
|
|
241
|
+
headers: { Accept: MIME.CardSource },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
let body = await response.text();
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { ok: true };
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
error: err instanceof Error ? err.message : String(err),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Search a realm using the `_search` endpoint.
|
|
263
|
+
*/
|
|
264
|
+
async search(
|
|
265
|
+
realmUrl: string,
|
|
266
|
+
query: Record<string, unknown>,
|
|
267
|
+
): Promise<SearchResult> {
|
|
268
|
+
let searchUrl = `${ensureTrailingSlash(realmUrl)}_search`;
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
let response = await this.pm.authedRealmFetch(searchUrl, {
|
|
272
|
+
method: 'QUERY',
|
|
273
|
+
headers: {
|
|
274
|
+
Accept: MIME.CardJson,
|
|
275
|
+
'Content-Type': MIME.JSON,
|
|
276
|
+
},
|
|
277
|
+
body: JSON.stringify(query),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
let body = await response.text();
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
status: response.status,
|
|
285
|
+
error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let result = (await response.json()) as {
|
|
290
|
+
data?: Record<string, unknown>[];
|
|
291
|
+
};
|
|
292
|
+
return { ok: true, data: result.data };
|
|
293
|
+
} catch (err) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
status: 0,
|
|
297
|
+
error: err instanceof Error ? err.message : String(err),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* List all file paths in a realm via the `_mtimes` endpoint.
|
|
304
|
+
* Returns relative paths (e.g., `hello.gts`, `Cards/my-card.json`).
|
|
305
|
+
*/
|
|
306
|
+
async listFiles(realmUrl: string): Promise<ListFilesResult> {
|
|
307
|
+
let normalizedRealmUrl = ensureTrailingSlash(realmUrl);
|
|
308
|
+
let mtimesUrl = `${normalizedRealmUrl}_mtimes`;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
let response = await this.pm.authedRealmFetch(mtimesUrl, {
|
|
312
|
+
method: 'GET',
|
|
313
|
+
headers: { Accept: MIME.JSONAPI },
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
let body = await response.text();
|
|
318
|
+
return {
|
|
319
|
+
filenames: [],
|
|
320
|
+
error: `_mtimes returned HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let json = (await response.json()) as {
|
|
325
|
+
data?: { attributes?: { mtimes?: Record<string, number> } };
|
|
326
|
+
};
|
|
327
|
+
let mtimes =
|
|
328
|
+
json?.data?.attributes?.mtimes ??
|
|
329
|
+
(json as unknown as Record<string, number>);
|
|
330
|
+
|
|
331
|
+
let filenames: string[] = [];
|
|
332
|
+
for (let fullUrl of Object.keys(mtimes)) {
|
|
333
|
+
if (!fullUrl.startsWith(normalizedRealmUrl)) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
let relativePath = fullUrl.slice(normalizedRealmUrl.length);
|
|
337
|
+
if (!relativePath || relativePath.endsWith('/')) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
filenames.push(relativePath);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { filenames: filenames.sort() };
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return {
|
|
346
|
+
filenames: [],
|
|
347
|
+
error: err instanceof Error ? err.message : String(err),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Execute a Boxel host command on the realm server via the `_run-command`
|
|
354
|
+
* endpoint. Uses the server-scoped token.
|
|
355
|
+
*/
|
|
356
|
+
async runCommand(
|
|
357
|
+
realmServerUrl: string,
|
|
358
|
+
realmUrl: string,
|
|
359
|
+
command: string,
|
|
360
|
+
commandInput?: Record<string, unknown>,
|
|
361
|
+
): Promise<RunCommandResult> {
|
|
362
|
+
let url = `${ensureTrailingSlash(realmServerUrl)}_run-command`;
|
|
363
|
+
let body = {
|
|
364
|
+
data: {
|
|
365
|
+
type: 'run-command',
|
|
366
|
+
attributes: {
|
|
367
|
+
realmURL: realmUrl,
|
|
368
|
+
command,
|
|
369
|
+
commandInput: commandInput ?? null,
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
let response: Response;
|
|
375
|
+
try {
|
|
376
|
+
response = await this.pm.authedRealmServerFetch(url, {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: {
|
|
379
|
+
Accept: MIME.JSONAPI,
|
|
380
|
+
'Content-Type': MIME.JSONAPI,
|
|
381
|
+
},
|
|
382
|
+
body: JSON.stringify(body),
|
|
383
|
+
});
|
|
384
|
+
} catch (err) {
|
|
385
|
+
return {
|
|
386
|
+
status: 'error',
|
|
387
|
+
error: `run-command fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!response.ok) {
|
|
392
|
+
return {
|
|
393
|
+
status: 'error',
|
|
394
|
+
error: `run-command HTTP ${response.status}: ${await response.text().catch(() => '(no body)')}`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let json = (await response.json()) as {
|
|
399
|
+
data?: {
|
|
400
|
+
attributes?: {
|
|
401
|
+
status?: string;
|
|
402
|
+
cardResultString?: string | null;
|
|
403
|
+
error?: string | null;
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
};
|
|
407
|
+
let attrs = json.data?.attributes;
|
|
408
|
+
return {
|
|
409
|
+
status: (attrs?.status as RunCommandResult['status']) ?? 'error',
|
|
410
|
+
result: attrs?.cardResultString ?? null,
|
|
411
|
+
error: attrs?.error ?? null,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Lint a single file's source code via the realm's `_lint` endpoint.
|
|
417
|
+
*/
|
|
418
|
+
async lint(
|
|
419
|
+
realmUrl: string,
|
|
420
|
+
source: string,
|
|
421
|
+
filename: string,
|
|
422
|
+
): Promise<LintResult> {
|
|
423
|
+
let lintUrl = `${ensureTrailingSlash(realmUrl)}_lint`;
|
|
424
|
+
let response = await this.pm.authedRealmFetch(lintUrl, {
|
|
425
|
+
method: 'POST',
|
|
426
|
+
headers: {
|
|
427
|
+
Accept: MIME.JSON,
|
|
428
|
+
'Content-Type': MIME.CardSource,
|
|
429
|
+
'X-Filename': filename,
|
|
430
|
+
'X-HTTP-Method-Override': 'QUERY',
|
|
431
|
+
},
|
|
432
|
+
body: source,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
if (!response.ok) {
|
|
436
|
+
let body = await response.text().catch(() => '(no body)');
|
|
437
|
+
throw new Error(
|
|
438
|
+
`_lint returned HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return (await response.json()) as LintResult;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Poll `_readiness-check` until the realm is ready or the timeout is reached.
|
|
447
|
+
*/
|
|
448
|
+
async waitForReady(
|
|
449
|
+
realmUrl: string,
|
|
450
|
+
timeoutMs = 30_000,
|
|
451
|
+
): Promise<WaitForReadyResult> {
|
|
452
|
+
let readinessUrl = `${ensureTrailingSlash(realmUrl)}_readiness-check`;
|
|
453
|
+
let startedAt = Date.now();
|
|
454
|
+
|
|
455
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
456
|
+
try {
|
|
457
|
+
let response = await this.pm.authedRealmFetch(readinessUrl, {
|
|
458
|
+
method: 'GET',
|
|
459
|
+
headers: { Accept: MIME.JSON },
|
|
460
|
+
});
|
|
461
|
+
if (response.ok) {
|
|
462
|
+
return { ready: true };
|
|
463
|
+
}
|
|
464
|
+
} catch {
|
|
465
|
+
// retry
|
|
466
|
+
}
|
|
467
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
ready: false,
|
|
472
|
+
error: `Realm not ready after ${timeoutMs}ms: ${readinessUrl}`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Poll a specific realm file path until it exists (non-404) or the timeout
|
|
478
|
+
* is reached. Useful after writing a file to wait for the realm to finish
|
|
479
|
+
* processing it before reading it back.
|
|
480
|
+
*/
|
|
481
|
+
async waitForFile(
|
|
482
|
+
realmUrl: string,
|
|
483
|
+
path: string,
|
|
484
|
+
options?: WaitForFileOptions,
|
|
485
|
+
): Promise<boolean> {
|
|
486
|
+
let timeoutMs = options?.timeoutMs ?? 30_000;
|
|
487
|
+
let pollMs = options?.pollMs ?? 300;
|
|
488
|
+
let startedAt = Date.now();
|
|
489
|
+
|
|
490
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
491
|
+
let result = await this.read(realmUrl, path);
|
|
492
|
+
if (result.ok) {
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Execute a batch of operations atomically against a realm.
|
|
503
|
+
* Operations is a JSON string of the `atomic:operations` array.
|
|
504
|
+
*/
|
|
505
|
+
async atomicOperation(
|
|
506
|
+
realmUrl: string,
|
|
507
|
+
operations: string,
|
|
508
|
+
): Promise<AtomicResult> {
|
|
509
|
+
let atomicUrl = `${ensureTrailingSlash(realmUrl)}_atomic`;
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
let response = await this.pm.authedRealmFetch(atomicUrl, {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
headers: {
|
|
515
|
+
Accept: MIME.JSONAPI,
|
|
516
|
+
'Content-Type': MIME.JSONAPI,
|
|
517
|
+
},
|
|
518
|
+
body: JSON.stringify({ 'atomic:operations': JSON.parse(operations) }),
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
if (!response.ok) {
|
|
522
|
+
let body = await response.text();
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let result = await response.json();
|
|
530
|
+
return { ok: true, response: result };
|
|
531
|
+
} catch (err) {
|
|
532
|
+
return {
|
|
533
|
+
ok: false,
|
|
534
|
+
error: err instanceof Error ? err.message : String(err),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Cancel all indexing jobs (running + pending) for a realm.
|
|
541
|
+
*/
|
|
542
|
+
async cancelAllIndexingJobs(realmUrl: string): Promise<CancelIndexingResult> {
|
|
543
|
+
let cancelUrl = `${ensureTrailingSlash(realmUrl)}_cancel-indexing-job`;
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
let response = await this.pm.authedRealmFetch(cancelUrl, {
|
|
547
|
+
method: 'POST',
|
|
548
|
+
headers: {
|
|
549
|
+
Accept: MIME.JSON,
|
|
550
|
+
'Content-Type': MIME.JSON,
|
|
551
|
+
},
|
|
552
|
+
body: JSON.stringify({ cancelPending: true }),
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (!response.ok) {
|
|
556
|
+
let body = await response.text();
|
|
557
|
+
return {
|
|
558
|
+
ok: false,
|
|
559
|
+
error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { ok: true };
|
|
564
|
+
} catch (err) {
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
error: err instanceof Error ? err.message : String(err),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Return the cached per-realm JWT for a realm URL, acquiring it via the
|
|
574
|
+
* realm server if necessary. Intended for scenarios like Playwright's
|
|
575
|
+
* `page.route()` that need a raw token to inject into browser-side fetches.
|
|
576
|
+
* Prefer `read`/`write`/`search` in normal code paths.
|
|
577
|
+
*/
|
|
578
|
+
async getRealmToken(realmUrl: string): Promise<string | undefined> {
|
|
579
|
+
return this.pm.getRealmTokenForUrl(realmUrl);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Perform an arbitrary fetch against a realm URL with the per-realm JWT
|
|
584
|
+
* injected automatically. Prefer the typed `read`/`write`/`search`
|
|
585
|
+
* helpers. Use this only for endpoints the typed helpers don't cover
|
|
586
|
+
* (e.g., loading a brief card from a source realm with custom headers).
|
|
587
|
+
*/
|
|
588
|
+
async authedFetch(
|
|
589
|
+
input: string | URL | Request,
|
|
590
|
+
init?: RequestInit,
|
|
591
|
+
): Promise<Response> {
|
|
592
|
+
return this.pm.authedRealmFetch(input, init);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Perform an arbitrary fetch against a realm server URL with the server
|
|
597
|
+
* JWT injected. Use for server-level endpoints not exposed as typed
|
|
598
|
+
* helpers (e.g., `_request-forward` for the OpenRouter proxy).
|
|
599
|
+
*/
|
|
600
|
+
async authedServerFetch(
|
|
601
|
+
input: string | URL | Request,
|
|
602
|
+
init?: RequestInit,
|
|
603
|
+
): Promise<Response> {
|
|
604
|
+
return this.pm.authedRealmServerFetch(input, init);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async pull(
|
|
608
|
+
realmUrl: string,
|
|
609
|
+
localDir: string,
|
|
610
|
+
options?: PullOptions,
|
|
611
|
+
): Promise<PullResult> {
|
|
612
|
+
return realmPull(realmUrl, localDir, {
|
|
613
|
+
delete: options?.delete,
|
|
614
|
+
profileManager: this.pm,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async createRealm(options: CreateRealmOptions): Promise<CreateRealmResult> {
|
|
619
|
+
let result = await coreCreateRealm(options.realmName, options.displayName, {
|
|
620
|
+
background: options.backgroundURL,
|
|
621
|
+
icon: options.iconURL,
|
|
622
|
+
profileManager: this.pm,
|
|
623
|
+
waitForReady: options.waitForReady !== false,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
realmUrl: result.realmUrl,
|
|
628
|
+
created: result.created,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
}
|