@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.
@@ -0,0 +1,647 @@
1
+ import type { ProfileManager } from './profile-manager';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import ignoreModule from 'ignore';
5
+ import pLimit from 'p-limit';
6
+
7
+ const ignore = (ignoreModule as any).default || ignoreModule;
8
+ type Ignore = ReturnType<typeof ignoreModule>;
9
+
10
+ // Files that must never be pushed, deleted, or overwritten on the server via CLI.
11
+ export const PROTECTED_FILES = new Set(['.realm.json']);
12
+
13
+ export function isProtectedFile(relativePath: string): boolean {
14
+ const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
15
+ return PROTECTED_FILES.has(normalizedPath);
16
+ }
17
+
18
+ async function pathExists(p: string): Promise<boolean> {
19
+ try {
20
+ await fs.access(p);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export const SupportedMimeType = {
28
+ CardSource: 'application/vnd.card+source',
29
+ DirectoryListing: 'application/vnd.api+json',
30
+ Mtimes: 'application/vnd.api+json',
31
+ } as const;
32
+
33
+ export interface SyncOptions {
34
+ realmUrl: string;
35
+ localDir: string;
36
+ dryRun?: boolean;
37
+ }
38
+
39
+ const REMOTE_CONCURRENCY = 10;
40
+
41
+ // Directories that should always be skipped during local file traversal,
42
+ // regardless of .gitignore / .boxelignore content.
43
+ const ALWAYS_IGNORED_DIRS = new Set(['node_modules']);
44
+
45
+ export abstract class RealmSyncBase {
46
+ protected normalizedRealmUrl: string;
47
+ private ignoreCache = new Map<string, Promise<Ignore>>();
48
+ protected remoteLimit = pLimit(REMOTE_CONCURRENCY);
49
+
50
+ constructor(
51
+ protected options: SyncOptions,
52
+ protected profileManager: ProfileManager,
53
+ ) {
54
+ this.normalizedRealmUrl = this.normalizeRealmUrl(options.realmUrl);
55
+ }
56
+
57
+ private normalizeRealmUrl(url: string): string {
58
+ try {
59
+ const urlObj = new URL(url);
60
+
61
+ const pathPart = urlObj.pathname;
62
+ const lastSegment = pathPart.split('/').filter(Boolean).pop() || '';
63
+
64
+ if (lastSegment.includes('.')) {
65
+ console.warn(
66
+ `Warning: "${url}" looks like a file URL, not a realm URL.` +
67
+ `\n Realm URLs should point to a directory (e.g., ${urlObj.origin}${pathPart.replace(/\/[^/]*\.[^/]*$/, '/')})`,
68
+ );
69
+ } else if (!url.endsWith('/')) {
70
+ console.warn(
71
+ `Warning: Realm URL should end with a trailing slash.` +
72
+ `\n Did you mean "${url}/"?`,
73
+ );
74
+ }
75
+
76
+ return urlObj.href.replace(/\/+$/, '') + '/';
77
+ } catch {
78
+ throw new Error(`Invalid workspace URL: ${url}`);
79
+ }
80
+ }
81
+
82
+ protected buildDirectoryUrl(dir = ''): string {
83
+ if (!dir) {
84
+ return this.normalizedRealmUrl;
85
+ }
86
+ const cleanDir = dir.replace(/^\/+|\/+$/g, '');
87
+ return `${this.normalizedRealmUrl}${cleanDir}/`;
88
+ }
89
+
90
+ protected buildFileUrl(relativePath: string): string {
91
+ const cleanPath = relativePath.replace(/^\/+/, '');
92
+ return `${this.normalizedRealmUrl}${cleanPath}`;
93
+ }
94
+
95
+ protected async getRemoteFileList(dir = ''): Promise<Map<string, boolean>> {
96
+ const files = new Map<string, boolean>();
97
+
98
+ try {
99
+ const url = this.buildDirectoryUrl(dir);
100
+
101
+ const response = await this.profileManager.authedRealmFetch(url, {
102
+ headers: {
103
+ Accept: 'application/vnd.api+json',
104
+ },
105
+ });
106
+
107
+ if (!response.ok) {
108
+ if (response.status === 404) {
109
+ return files;
110
+ }
111
+ if (response.status === 401 || response.status === 403) {
112
+ throw new Error(
113
+ `Authentication failed (${response.status}): Cannot access workspace. Check your Matrix credentials and workspace permissions.`,
114
+ );
115
+ }
116
+ throw new Error(
117
+ `Failed to get directory listing: ${response.status} ${response.statusText}`,
118
+ );
119
+ }
120
+
121
+ const data = (await response.json()) as {
122
+ data?: {
123
+ relationships?: Record<string, { meta: { kind: string } }>;
124
+ };
125
+ };
126
+
127
+ if (data.data && data.data.relationships) {
128
+ const entries = Object.entries(data.data.relationships);
129
+ const subResults = await Promise.all(
130
+ entries.map(([name, info]) => {
131
+ const entry = info as { meta: { kind: string } };
132
+ const isFile = entry.meta.kind === 'file';
133
+ const entryPath = dir ? path.posix.join(dir, name) : name;
134
+
135
+ if (isFile) {
136
+ if (!this.shouldIgnoreRemoteFile(entryPath)) {
137
+ return [[entryPath, true as boolean]] as Array<
138
+ [string, boolean]
139
+ >;
140
+ }
141
+ return [] as Array<[string, boolean]>;
142
+ } else {
143
+ return this.remoteLimit(async () => {
144
+ const subdirFiles = await this.getRemoteFileList(entryPath);
145
+ return Array.from(subdirFiles.entries());
146
+ });
147
+ }
148
+ }),
149
+ );
150
+
151
+ for (const pairs of subResults) {
152
+ for (const [p, isFileEntry] of pairs) {
153
+ files.set(p, isFileEntry);
154
+ }
155
+ }
156
+ }
157
+ } catch (error) {
158
+ if (error instanceof Error) {
159
+ if (
160
+ error.message.includes('Authentication failed') ||
161
+ error.message.includes('Cannot access workspace') ||
162
+ error.message.includes('401') ||
163
+ error.message.includes('403')
164
+ ) {
165
+ throw error;
166
+ }
167
+ }
168
+ console.error(`Error reading remote directory ${dir}:`, error);
169
+ throw error;
170
+ }
171
+
172
+ return files;
173
+ }
174
+
175
+ protected async getRemoteMtimes(): Promise<Map<string, number>> {
176
+ const mtimes = new Map<string, number>();
177
+
178
+ try {
179
+ const url = `${this.normalizedRealmUrl}_mtimes`;
180
+
181
+ const response = await this.profileManager.authedRealmFetch(url, {
182
+ headers: {
183
+ Accept: SupportedMimeType.Mtimes,
184
+ },
185
+ });
186
+
187
+ if (!response.ok) {
188
+ if (response.status === 404) {
189
+ console.log(
190
+ 'Note: _mtimes endpoint not available, will upload all files',
191
+ );
192
+ return mtimes;
193
+ }
194
+ throw new Error(
195
+ `Failed to get mtimes: ${response.status} ${response.statusText}`,
196
+ );
197
+ }
198
+
199
+ const data = (await response.json()) as {
200
+ data?: {
201
+ attributes?: {
202
+ mtimes?: Record<string, number>;
203
+ };
204
+ };
205
+ };
206
+
207
+ if (data.data?.attributes?.mtimes) {
208
+ const remoteMtimeEntries = Object.entries(data.data.attributes.mtimes);
209
+ if (process.env.DEBUG) {
210
+ console.log(
211
+ `Remote mtimes received: ${remoteMtimeEntries.length} entries`,
212
+ );
213
+ if (remoteMtimeEntries.length > 0) {
214
+ console.log(
215
+ `Sample: ${remoteMtimeEntries[0][0]} = ${remoteMtimeEntries[0][1]}`,
216
+ );
217
+ }
218
+ }
219
+ for (const [fileUrl, mtime] of remoteMtimeEntries) {
220
+ const relativePath = fileUrl.replace(this.normalizedRealmUrl, '');
221
+ if (!this.shouldIgnoreRemoteFile(relativePath)) {
222
+ mtimes.set(relativePath, mtime);
223
+ }
224
+ }
225
+ } else if (process.env.DEBUG) {
226
+ console.log(
227
+ 'No mtimes in response:',
228
+ JSON.stringify(data).slice(0, 200),
229
+ );
230
+ }
231
+ } catch (error) {
232
+ console.warn(
233
+ 'Could not fetch remote mtimes, will upload all files:',
234
+ error,
235
+ );
236
+ }
237
+
238
+ return mtimes;
239
+ }
240
+
241
+ protected async getLocalFileListWithMtimes(
242
+ dir = '',
243
+ ): Promise<Map<string, { path: string; mtime: number }>> {
244
+ const files = new Map<string, { path: string; mtime: number }>();
245
+ const fullDir = path.join(this.options.localDir, dir);
246
+
247
+ let entries;
248
+ try {
249
+ entries = await fs.readdir(fullDir, { withFileTypes: true });
250
+ } catch (err: any) {
251
+ if (err.code === 'ENOENT') return files;
252
+ throw err;
253
+ }
254
+
255
+ const subResults = await Promise.all(
256
+ entries.map(async (entry) => {
257
+ const fullPath = path.join(fullDir, entry.name);
258
+ const relativePath = dir
259
+ ? path.posix.join(dir, entry.name)
260
+ : entry.name;
261
+
262
+ if (entry.isDirectory() && ALWAYS_IGNORED_DIRS.has(entry.name)) {
263
+ return [] as Array<[string, { path: string; mtime: number }]>;
264
+ }
265
+
266
+ if (await this.shouldIgnoreFile(relativePath, fullPath)) {
267
+ return [] as Array<[string, { path: string; mtime: number }]>;
268
+ }
269
+
270
+ if (entry.isFile()) {
271
+ const stats = await fs.stat(fullPath);
272
+ return [
273
+ [relativePath, { path: fullPath, mtime: stats.mtimeMs }],
274
+ ] as Array<[string, { path: string; mtime: number }]>;
275
+ } else if (entry.isDirectory()) {
276
+ const subdirFiles =
277
+ await this.getLocalFileListWithMtimes(relativePath);
278
+ return Array.from(subdirFiles.entries());
279
+ }
280
+ return [];
281
+ }),
282
+ );
283
+
284
+ for (const pairs of subResults) {
285
+ for (const [p, info] of pairs) {
286
+ files.set(p, info);
287
+ }
288
+ }
289
+
290
+ return files;
291
+ }
292
+
293
+ protected async getLocalFileList(dir = ''): Promise<Map<string, string>> {
294
+ const files = new Map<string, string>();
295
+ const fullDir = path.join(this.options.localDir, dir);
296
+
297
+ let entries;
298
+ try {
299
+ entries = await fs.readdir(fullDir, { withFileTypes: true });
300
+ } catch (err: any) {
301
+ if (err.code === 'ENOENT') return files;
302
+ throw err;
303
+ }
304
+
305
+ const subResults = await Promise.all(
306
+ entries.map(async (entry) => {
307
+ const fullPath = path.join(fullDir, entry.name);
308
+ const relativePath = dir
309
+ ? path.posix.join(dir, entry.name)
310
+ : entry.name;
311
+
312
+ if (entry.isDirectory() && ALWAYS_IGNORED_DIRS.has(entry.name)) {
313
+ return [] as Array<[string, string]>;
314
+ }
315
+
316
+ if (await this.shouldIgnoreFile(relativePath, fullPath)) {
317
+ return [] as Array<[string, string]>;
318
+ }
319
+
320
+ if (entry.isFile()) {
321
+ return [[relativePath, fullPath]] as Array<[string, string]>;
322
+ } else if (entry.isDirectory()) {
323
+ const subdirFiles = await this.getLocalFileList(relativePath);
324
+ return Array.from(subdirFiles.entries());
325
+ }
326
+ return [];
327
+ }),
328
+ );
329
+
330
+ for (const pairs of subResults) {
331
+ for (const [p, fullSubPath] of pairs) {
332
+ files.set(p, fullSubPath);
333
+ }
334
+ }
335
+
336
+ return files;
337
+ }
338
+
339
+ protected async uploadFile(
340
+ relativePath: string,
341
+ localPath: string,
342
+ ): Promise<void> {
343
+ if (isProtectedFile(relativePath)) {
344
+ console.log(` Skipped (protected): ${relativePath}`);
345
+ return;
346
+ }
347
+
348
+ console.log(`Uploading: ${relativePath}`);
349
+
350
+ if (this.options.dryRun) {
351
+ console.log(`[DRY RUN] Would upload ${relativePath}`);
352
+ return;
353
+ }
354
+
355
+ const content = await fs.readFile(localPath, 'utf8');
356
+ const url = this.buildFileUrl(relativePath);
357
+
358
+ const response = await this.profileManager.authedRealmFetch(url, {
359
+ method: 'POST',
360
+ headers: {
361
+ 'Content-Type': 'text/plain;charset=UTF-8',
362
+ Accept: SupportedMimeType.CardSource,
363
+ },
364
+ body: content,
365
+ });
366
+
367
+ if (!response.ok) {
368
+ throw new Error(
369
+ `Failed to upload: ${response.status} ${response.statusText}`,
370
+ );
371
+ }
372
+
373
+ console.log(` Uploaded: ${relativePath}`);
374
+ }
375
+
376
+ // Batched upload via the realm's /_atomic endpoint. Returns the set of
377
+ // paths the server reported as written plus an optional error payload
378
+ // when the whole batch was rejected. The atomic endpoint validates
379
+ // every operation first (existence checks for add/update), so a 409 on
380
+ // any `add` or a 404 on any `update` causes the whole batch to fail
381
+ // with no side effects on the realm.
382
+ protected async uploadFilesAtomic(
383
+ files: Map<string, string>,
384
+ addPaths: Set<string>,
385
+ ): Promise<{
386
+ succeeded: string[];
387
+ error?: {
388
+ status: number;
389
+ perFile: Array<{ path: string; status: number; title: string }>;
390
+ message: string;
391
+ };
392
+ }> {
393
+ const entries = Array.from(files.entries()).filter(
394
+ ([relativePath]) => !isProtectedFile(relativePath),
395
+ );
396
+
397
+ if (entries.length === 0) {
398
+ return { succeeded: [] };
399
+ }
400
+
401
+ if (this.options.dryRun) {
402
+ for (const [relativePath] of entries) {
403
+ console.log(`[DRY RUN] Would upload ${relativePath}`);
404
+ }
405
+ return { succeeded: [] };
406
+ }
407
+
408
+ const operations = await Promise.all(
409
+ entries.map(async ([relativePath, localPath]) => {
410
+ const content = await fs.readFile(localPath, 'utf8');
411
+ return {
412
+ op: addPaths.has(relativePath)
413
+ ? ('add' as const)
414
+ : ('update' as const),
415
+ href: this.buildFileUrl(relativePath),
416
+ data: {
417
+ type: 'source' as const,
418
+ attributes: { content },
419
+ meta: {},
420
+ },
421
+ };
422
+ }),
423
+ );
424
+
425
+ const url = `${this.normalizedRealmUrl}_atomic`;
426
+ const response = await this.profileManager.authedRealmFetch(url, {
427
+ method: 'POST',
428
+ headers: {
429
+ 'Content-Type': 'application/vnd.api+json',
430
+ Accept: 'application/vnd.api+json',
431
+ },
432
+ body: JSON.stringify({ 'atomic:operations': operations }),
433
+ });
434
+
435
+ if (response.status === 201) {
436
+ const body = (await response.json()) as {
437
+ 'atomic:results'?: Array<{ data?: { id?: string } }>;
438
+ };
439
+ const hrefToRelative = new Map(
440
+ entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
441
+ );
442
+ const succeeded = (body['atomic:results'] ?? [])
443
+ .map((r) => r.data?.id)
444
+ .filter((id): id is string => typeof id === 'string')
445
+ .map((id) => hrefToRelative.get(id) ?? id);
446
+ for (const rel of succeeded) {
447
+ console.log(` Uploaded: ${rel}`);
448
+ }
449
+ return { succeeded };
450
+ }
451
+
452
+ let errorBody: {
453
+ errors?: Array<{ title?: string; detail?: string; status?: number }>;
454
+ } = {};
455
+ try {
456
+ errorBody = (await response.json()) as typeof errorBody;
457
+ } catch {
458
+ // ignore JSON parse failures — fall through to the generic message
459
+ }
460
+
461
+ const perFile = (errorBody.errors ?? []).map((e) => {
462
+ const detail = e.detail ?? '';
463
+ const match = detail.match(/Resource (\S+) /);
464
+ const href = match ? match[1] : '';
465
+ const relMap = new Map(
466
+ entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
467
+ );
468
+ return {
469
+ path: relMap.get(href) ?? href,
470
+ status: e.status ?? response.status,
471
+ title: e.title ?? 'Error',
472
+ };
473
+ });
474
+
475
+ return {
476
+ succeeded: [],
477
+ error: {
478
+ status: response.status,
479
+ perFile,
480
+ message: `Atomic upload failed: ${response.status} ${response.statusText}`,
481
+ },
482
+ };
483
+ }
484
+
485
+ protected async downloadFile(
486
+ relativePath: string,
487
+ localPath: string,
488
+ ): Promise<void> {
489
+ console.log(`Downloading: ${relativePath}`);
490
+
491
+ if (this.options.dryRun) {
492
+ console.log(`[DRY RUN] Would download ${relativePath}`);
493
+ return;
494
+ }
495
+
496
+ const url = this.buildFileUrl(relativePath);
497
+
498
+ const response = await this.profileManager.authedRealmFetch(url, {
499
+ headers: {
500
+ Accept: SupportedMimeType.CardSource,
501
+ },
502
+ });
503
+
504
+ if (!response.ok) {
505
+ throw new Error(
506
+ `Failed to download: ${response.status} ${response.statusText}`,
507
+ );
508
+ }
509
+
510
+ const content = await response.text();
511
+
512
+ const localDir = path.dirname(localPath);
513
+ await fs.mkdir(localDir, { recursive: true });
514
+
515
+ await fs.writeFile(localPath, content, 'utf8');
516
+ console.log(` Downloaded: ${relativePath}`);
517
+ }
518
+
519
+ protected async deleteFile(relativePath: string): Promise<void> {
520
+ if (isProtectedFile(relativePath)) {
521
+ console.log(` Skipped (protected): ${relativePath}`);
522
+ return;
523
+ }
524
+
525
+ console.log(`Deleting remote: ${relativePath}`);
526
+
527
+ if (this.options.dryRun) {
528
+ console.log(`[DRY RUN] Would delete ${relativePath}`);
529
+ return;
530
+ }
531
+
532
+ const url = this.buildFileUrl(relativePath);
533
+
534
+ const response = await this.profileManager.authedRealmFetch(url, {
535
+ method: 'DELETE',
536
+ headers: {
537
+ Accept: SupportedMimeType.CardSource,
538
+ },
539
+ });
540
+
541
+ if (!response.ok && response.status !== 404) {
542
+ throw new Error(
543
+ `Failed to delete: ${response.status} ${response.statusText}`,
544
+ );
545
+ }
546
+
547
+ console.log(` Deleted: ${relativePath}`);
548
+ }
549
+
550
+ protected async deleteLocalFile(localPath: string): Promise<void> {
551
+ console.log(`Deleting local: ${localPath}`);
552
+
553
+ if (this.options.dryRun) {
554
+ console.log(`[DRY RUN] Would delete local file ${localPath}`);
555
+ return;
556
+ }
557
+
558
+ try {
559
+ await fs.unlink(localPath);
560
+ console.log(` Deleted: ${localPath}`);
561
+ } catch (err: any) {
562
+ if (err.code !== 'ENOENT') throw err;
563
+ }
564
+ }
565
+
566
+ private getIgnoreInstance(dirPath: string): Promise<Ignore> {
567
+ const cached = this.ignoreCache.get(dirPath);
568
+ if (cached) return cached;
569
+
570
+ const build = (async () => {
571
+ const ig = ignore();
572
+ let currentPath = dirPath;
573
+ const rootPath = this.options.localDir;
574
+
575
+ while (currentPath.startsWith(rootPath)) {
576
+ const gitignorePath = path.join(currentPath, '.gitignore');
577
+ if (await pathExists(gitignorePath)) {
578
+ try {
579
+ const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
580
+ ig.add(gitignoreContent);
581
+ } catch (error) {
582
+ console.warn(
583
+ `Warning: Could not read .gitignore file at ${gitignorePath}:`,
584
+ error,
585
+ );
586
+ }
587
+ }
588
+
589
+ const boxelignorePath = path.join(currentPath, '.boxelignore');
590
+ if (await pathExists(boxelignorePath)) {
591
+ try {
592
+ const boxelignoreContent = await fs.readFile(
593
+ boxelignorePath,
594
+ 'utf8',
595
+ );
596
+ ig.add(boxelignoreContent);
597
+ } catch (error) {
598
+ console.warn(
599
+ `Warning: Could not read .boxelignore file at ${boxelignorePath}:`,
600
+ error,
601
+ );
602
+ }
603
+ }
604
+
605
+ const parentPath = path.dirname(currentPath);
606
+ if (parentPath === currentPath) break;
607
+ currentPath = parentPath;
608
+ }
609
+
610
+ return ig;
611
+ })();
612
+
613
+ this.ignoreCache.set(dirPath, build);
614
+ return build;
615
+ }
616
+
617
+ private async shouldIgnoreFile(
618
+ relativePath: string,
619
+ fullPath: string,
620
+ ): Promise<boolean> {
621
+ const fileName = path.basename(relativePath);
622
+
623
+ if (fileName === '.boxel-sync.json') {
624
+ return true;
625
+ }
626
+
627
+ if (fileName.startsWith('.')) {
628
+ return true;
629
+ }
630
+
631
+ const dirPath = path.dirname(fullPath);
632
+ const ig = await this.getIgnoreInstance(dirPath);
633
+ const normalizedPath = relativePath.replace(/\\/g, '/');
634
+
635
+ return ig.ignores(normalizedPath);
636
+ }
637
+
638
+ private shouldIgnoreRemoteFile(relativePath: string): boolean {
639
+ const fileName = path.basename(relativePath);
640
+ if (fileName.startsWith('.')) {
641
+ return true;
642
+ }
643
+ return false;
644
+ }
645
+
646
+ abstract sync(): Promise<void>;
647
+ }