@cardstack/boxel-cli 0.0.1 → 0.1.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.
Files changed (42) hide show
  1. package/README.md +124 -0
  2. package/api.ts +3 -0
  3. package/bin/boxel.js +15 -0
  4. package/dist/index.js +107 -66
  5. package/package.json +35 -26
  6. package/src/build-program.ts +91 -0
  7. package/src/commands/file/delete.ts +110 -0
  8. package/src/commands/file/index.ts +20 -0
  9. package/src/commands/file/lint.ts +235 -0
  10. package/src/commands/file/list.ts +121 -0
  11. package/src/commands/file/read.ts +113 -0
  12. package/src/commands/file/touch.ts +222 -0
  13. package/src/commands/file/write.ts +152 -0
  14. package/src/commands/profile.ts +199 -106
  15. package/src/commands/read-transpiled.ts +120 -0
  16. package/src/commands/realm/cancel-indexing.ts +113 -0
  17. package/src/commands/realm/create.ts +1 -4
  18. package/src/commands/realm/history.ts +388 -0
  19. package/src/commands/realm/index.ts +12 -0
  20. package/src/commands/realm/list.ts +156 -0
  21. package/src/commands/realm/pull.ts +51 -17
  22. package/src/commands/realm/push.ts +79 -27
  23. package/src/commands/realm/remove.ts +281 -0
  24. package/src/commands/realm/sync.ts +160 -60
  25. package/src/commands/realm/wait-for-ready.ts +120 -0
  26. package/src/commands/realm/watch.ts +626 -0
  27. package/src/commands/run-command.ts +4 -3
  28. package/src/commands/search.ts +160 -0
  29. package/src/index.ts +16 -38
  30. package/src/lib/auth-resolver.ts +58 -0
  31. package/src/lib/auth.ts +56 -12
  32. package/src/lib/boxel-cli-client.ts +146 -279
  33. package/src/lib/cli-log.ts +132 -0
  34. package/src/lib/colors.ts +14 -9
  35. package/src/lib/find-checkpoint.ts +65 -0
  36. package/src/lib/profile-manager.ts +49 -4
  37. package/src/lib/prompt.ts +133 -0
  38. package/src/lib/realm-authenticator.ts +12 -0
  39. package/src/lib/realm-sync-base.ts +122 -16
  40. package/src/lib/seed-auth.ts +214 -0
  41. package/src/lib/watch-lock.ts +81 -0
  42. package/LICENSE +0 -21
@@ -0,0 +1,65 @@
1
+ import type { Checkpoint } from './checkpoint-manager';
2
+
3
+ /**
4
+ * Length of a checkpoint's `shortHash`. SHA-1's first 7 hex chars, set in
5
+ * `CheckpointManager.createCheckpoint`. Used to short-circuit the exact
6
+ * short-hash scan for refs that can't possibly be one.
7
+ */
8
+ const SHORT_HASH_LENGTH = 7;
9
+
10
+ export type FindResult =
11
+ | { kind: 'found'; target: Checkpoint }
12
+ | { kind: 'none' }
13
+ | { kind: 'ambiguous'; matches: Checkpoint[] };
14
+
15
+ /**
16
+ * Resolve a `--restore` ref against a list of checkpoints. The ref may be a
17
+ * 1-based index (`'2'`), an exact short hash (`'6701186'`), or a hex prefix
18
+ * of a full hash (`'abc'`).
19
+ *
20
+ * Resolution order:
21
+ * 1. Exact short-hash match. SHA-1 short hashes are all-digits ~5.9% of
22
+ * the time, so digit-only refs that match a short hash exactly must
23
+ * win before the index branch.
24
+ * 2. Digit-only refs → 1-based index. Out-of-range returns `none` rather
25
+ * than falling through to hash-prefix matching, since silently matching
26
+ * a hash whose prefix happens to be digits would surprise users typing
27
+ * what they think is an index.
28
+ * 3. Hex-prefix match against the full hash.
29
+ */
30
+ export function findCheckpoint(
31
+ ref: string,
32
+ checkpoints: Checkpoint[],
33
+ ): FindResult {
34
+ const trimmed = ref.trim();
35
+ // Empty refs would `startsWith('')`-match every hash and silently restore
36
+ // the newest checkpoint — guard explicitly.
37
+ if (trimmed === '') return { kind: 'none' };
38
+
39
+ // Exact short-hash match wins before the digit-only branch.
40
+ if (trimmed.length === SHORT_HASH_LENGTH) {
41
+ const exactShort = checkpoints.filter((cp) => cp.shortHash === trimmed);
42
+ if (exactShort.length === 1) {
43
+ return { kind: 'found', target: exactShort[0] };
44
+ }
45
+ if (exactShort.length > 1) {
46
+ return { kind: 'ambiguous', matches: exactShort };
47
+ }
48
+ }
49
+
50
+ // Digit-only input is an index lookup. Falling through to hash-prefix
51
+ // matching when out of range would silently match short hashes whose prefix
52
+ // happens to be digits.
53
+ if (/^\d+$/.test(trimmed)) {
54
+ const num = parseInt(trimmed, 10);
55
+ if (num >= 1 && num <= checkpoints.length) {
56
+ return { kind: 'found', target: checkpoints[num - 1] };
57
+ }
58
+ return { kind: 'none' };
59
+ }
60
+
61
+ const matches = checkpoints.filter((cp) => cp.hash.startsWith(trimmed));
62
+ if (matches.length === 0) return { kind: 'none' };
63
+ if (matches.length === 1) return { kind: 'found', target: matches[0] };
64
+ return { kind: 'ambiguous', matches };
65
+ }
@@ -7,12 +7,18 @@ import {
7
7
  getRealmServerToken as fetchRealmServerToken,
8
8
  getRealmTokens,
9
9
  addRealmToMatrixAccountData,
10
+ removeRealmFromMatrixAccountData,
11
+ getUserRealmsFromMatrixAccountData,
10
12
  type MatrixAuth,
11
13
  } from './auth';
14
+ import type { RealmAuthenticator } from './realm-authenticator';
12
15
 
13
16
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli');
14
17
  const PROFILES_FILENAME = 'profiles.json';
15
18
 
19
+ export const NO_ACTIVE_PROFILE_ERROR =
20
+ 'No active profile. Run `boxel profile add` to create one.';
21
+
16
22
  export interface Profile {
17
23
  displayName: string;
18
24
  matrixUrl: string;
@@ -77,15 +83,15 @@ export function getEnvironmentLabel(env: Environment): string {
77
83
 
78
84
  /**
79
85
  * Format profile for display in command output
80
- * @example [ctse · staging]
86
+ * @example [ctse · stack.cards]
81
87
  */
82
88
  export function formatProfileBadge(matrixId: string): string {
83
89
  const username = getUsernameFromMatrixId(matrixId);
84
- const env = getEnvironmentLabel(getEnvironmentFromMatrixId(matrixId));
85
- return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${env}${RESET}${DIM}]${RESET}`;
90
+ const domain = getDomainFromMatrixId(matrixId);
91
+ return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${domain}${RESET}${DIM}]${RESET}`;
86
92
  }
87
93
 
88
- export class ProfileManager {
94
+ export class ProfileManager implements RealmAuthenticator {
89
95
  private config: ProfilesConfig;
90
96
  private configDir: string;
91
97
  private profilesFile: string;
@@ -293,6 +299,35 @@ export class ProfileManager {
293
299
  return true;
294
300
  }
295
301
 
302
+ // Update one or both server URLs for an existing profile. Cached realm
303
+ // tokens (and the realm-server token) are tied to the previous servers,
304
+ // so they're cleared whenever URLs actually change.
305
+ // Returns true iff at least one URL changed.
306
+ updateUrls(
307
+ profileId: string,
308
+ urls: { matrixUrl?: string; realmServerUrl?: string },
309
+ ): boolean {
310
+ const profile = this.config.profiles[profileId];
311
+ if (!profile) {
312
+ return false;
313
+ }
314
+ let changed = false;
315
+ if (urls.matrixUrl && urls.matrixUrl !== profile.matrixUrl) {
316
+ profile.matrixUrl = urls.matrixUrl;
317
+ changed = true;
318
+ }
319
+ if (urls.realmServerUrl && urls.realmServerUrl !== profile.realmServerUrl) {
320
+ profile.realmServerUrl = urls.realmServerUrl;
321
+ changed = true;
322
+ }
323
+ if (changed) {
324
+ profile.realmTokens = undefined;
325
+ profile.realmServerToken = undefined;
326
+ this.saveConfig();
327
+ }
328
+ return changed;
329
+ }
330
+
296
331
  setRealmToken(realmUrl: string, token: string): void {
297
332
  let active = this.getActiveProfile();
298
333
  if (!active) {
@@ -490,6 +525,16 @@ export class ProfileManager {
490
525
  await addRealmToMatrixAccountData(matrixAuth, realmUrl);
491
526
  }
492
527
 
528
+ async removeFromUserRealms(realmUrl: string): Promise<boolean> {
529
+ let matrixAuth = await this.loginToMatrix();
530
+ return removeRealmFromMatrixAccountData(matrixAuth, realmUrl);
531
+ }
532
+
533
+ async getUserRealms(): Promise<string[]> {
534
+ let matrixAuth = await this.loginToMatrix();
535
+ return getUserRealmsFromMatrixAccountData(matrixAuth);
536
+ }
537
+
493
538
  async migrateFromEnv(): Promise<{
494
539
  profileId: string;
495
540
  created: boolean;
@@ -0,0 +1,133 @@
1
+ import * as readline from 'readline';
2
+ import { Writable } from 'stream';
3
+
4
+ export function prompt(question: string): Promise<string> {
5
+ const rl = readline.createInterface({
6
+ input: process.stdin,
7
+ output: process.stdout,
8
+ });
9
+
10
+ return new Promise((resolve) => {
11
+ rl.question(question, (answer) => {
12
+ rl.close();
13
+ resolve(answer.trim());
14
+ });
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Read a secret from stdin without echoing it to the TTY. Keystrokes are
20
+ * masked with `*` and Ctrl+C exits. Intended for seeds, passwords, and other
21
+ * sensitive CLI input that must not appear in shell history or `ps aux`.
22
+ */
23
+ export function promptPassword(question: string): Promise<string> {
24
+ const mutableOutput = new Writable({
25
+ write: (_chunk, _encoding, callback) => callback(),
26
+ });
27
+ const rl = readline.createInterface({
28
+ input: process.stdin,
29
+ output: mutableOutput,
30
+ terminal: true,
31
+ });
32
+
33
+ return new Promise((resolve, reject) => {
34
+ const stdin = process.stdin;
35
+ const wasFlowing = stdin.readableFlowing;
36
+
37
+ if (stdin.isTTY) {
38
+ stdin.setRawMode(true);
39
+ }
40
+
41
+ const cleanup = () => {
42
+ stdin.removeListener('data', onData);
43
+ if (stdin.isTTY) {
44
+ stdin.setRawMode(false);
45
+ }
46
+ rl.close();
47
+ if (!wasFlowing) {
48
+ stdin.pause();
49
+ }
50
+ };
51
+
52
+ const onData = (chunk: Buffer) => {
53
+ try {
54
+ // Pastes arrive as a single data event containing many characters.
55
+ // Strip bracketed-paste markers if the terminal sent them, then walk
56
+ // the chunk one code point at a time so newlines, backspace, and
57
+ // Ctrl+C inside a paste still work.
58
+ const raw = chunk
59
+ .toString()
60
+ .split('[200~')
61
+ .join('')
62
+ .split('[201~')
63
+ .join('');
64
+ for (const c of raw) {
65
+ if (c === '\n' || c === '\r') {
66
+ cleanup();
67
+ process.stdout.write('\n');
68
+ resolve(password);
69
+ return;
70
+ } else if (c === '\u0003') {
71
+ // Ctrl+C
72
+ cleanup();
73
+ process.exit();
74
+ } else if (c === '\u007F' || c === '\b') {
75
+ // Backspace
76
+ if (password.length > 0) {
77
+ password = password.slice(0, -1);
78
+ process.stdout.write('\b \b');
79
+ }
80
+ } else if (c >= ' ') {
81
+ // Printable character; suppress other control bytes entirely.
82
+ password += c;
83
+ process.stdout.write('*');
84
+ }
85
+ }
86
+ } catch (e) {
87
+ cleanup();
88
+ reject(e);
89
+ }
90
+ };
91
+
92
+ let password = '';
93
+ try {
94
+ process.stdout.write(question);
95
+ stdin.on('data', onData);
96
+ stdin.resume();
97
+ } catch (e) {
98
+ cleanup();
99
+ reject(e);
100
+ }
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Resolve a realm secret seed for administrative CLI operations.
106
+ *
107
+ * Precedence:
108
+ * 1. `BOXEL_REALM_SECRET_SEED` env var — used silently if set.
109
+ * 2. If `flagPresent` is true, prompt the user (no echo).
110
+ * 3. Otherwise return undefined — caller falls back to profile auth.
111
+ *
112
+ * Throws when `--realm-secret-seed` is requested but stdin is not a TTY
113
+ * (e.g. CI, piped shells) — otherwise `promptPassword` would hang
114
+ * indefinitely waiting for keypress input it can never receive.
115
+ */
116
+ export async function resolveRealmSecretSeed(
117
+ flagPresent: boolean,
118
+ ): Promise<string | undefined> {
119
+ const fromEnv = process.env.BOXEL_REALM_SECRET_SEED;
120
+ if (fromEnv) {
121
+ return fromEnv;
122
+ }
123
+ if (!flagPresent) {
124
+ return undefined;
125
+ }
126
+ if (!process.stdin.isTTY) {
127
+ throw new Error(
128
+ 'Cannot prompt for realm secret seed: stdin is not a TTY. ' +
129
+ 'Set BOXEL_REALM_SECRET_SEED in the environment instead.',
130
+ );
131
+ }
132
+ return promptPassword('Realm secret seed: ');
133
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Narrow interface over whatever strategy provides authenticated fetch to a
3
+ * realm. Both `ProfileManager` (Matrix login + per-realm JWT) and
4
+ * `SeedAuthenticator` (mint a JWT directly from a shared secret seed) satisfy
5
+ * this interface, so `RealmSyncBase` can accept either.
6
+ */
7
+ export interface RealmAuthenticator {
8
+ authedRealmFetch(
9
+ input: string | URL | Request,
10
+ init?: RequestInit,
11
+ ): Promise<Response>;
12
+ }
@@ -1,4 +1,4 @@
1
- import type { ProfileManager } from './profile-manager';
1
+ import type { RealmAuthenticator } from './realm-authenticator';
2
2
  import * as fs from 'fs/promises';
3
3
  import * as path from 'path';
4
4
  import ignoreModule from 'ignore';
@@ -9,6 +9,8 @@ type Ignore = ReturnType<typeof ignoreModule>;
9
9
 
10
10
  // Files that must never be pushed, deleted, or overwritten on the server via CLI.
11
11
  export const PROTECTED_FILES = new Set(['.realm.json']);
12
+ const DELETE_TIMEOUT_MS = 10_000;
13
+ const DELETE_TIMEOUT_PROBE_MS = 3_000;
12
14
 
13
15
  export function isProtectedFile(relativePath: string): boolean {
14
16
  const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
@@ -24,6 +26,22 @@ async function pathExists(p: string): Promise<boolean> {
24
26
  }
25
27
  }
26
28
 
29
+ /**
30
+ * Decode an `atomic:results` `data.id` (or any href the realm echoes
31
+ * back with URL-encoded path segments). Used so paths that contain
32
+ * spaces or other characters that get percent-encoded on the wire
33
+ * round-trip to the same relative path the local listing uses.
34
+ * Falls back to the raw value on a malformed escape so a single bad
35
+ * entry can't kill the whole sync.
36
+ */
37
+ function decodeAtomicResultId(id: string): string {
38
+ try {
39
+ return decodeURIComponent(id);
40
+ } catch {
41
+ return id;
42
+ }
43
+ }
44
+
27
45
  export const SupportedMimeType = {
28
46
  CardSource: 'application/vnd.card+source',
29
47
  DirectoryListing: 'application/vnd.api+json',
@@ -34,6 +52,15 @@ export interface SyncOptions {
34
52
  realmUrl: string;
35
53
  localDir: string;
36
54
  dryRun?: boolean;
55
+ /**
56
+ * Append `?waitForIndex=true` to the `_atomic` POST so the realm-server
57
+ * returns only after the indexer has processed the batch. The
58
+ * `_atomic` handler hardcoded `waitForIndex: false` after CS-11003
59
+ * PR 2 (deferred `+source` POST), so callers that read indexed state
60
+ * (search / list) immediately after a sync race the indexer. Off by
61
+ * default.
62
+ */
63
+ waitForIndex?: boolean;
37
64
  }
38
65
 
39
66
  const REMOTE_CONCURRENCY = 10;
@@ -49,7 +76,7 @@ export abstract class RealmSyncBase {
49
76
 
50
77
  constructor(
51
78
  protected options: SyncOptions,
52
- protected profileManager: ProfileManager,
79
+ protected authenticator: RealmAuthenticator,
53
80
  ) {
54
81
  this.normalizedRealmUrl = this.normalizeRealmUrl(options.realmUrl);
55
82
  }
@@ -98,7 +125,7 @@ export abstract class RealmSyncBase {
98
125
  try {
99
126
  const url = this.buildDirectoryUrl(dir);
100
127
 
101
- const response = await this.profileManager.authedRealmFetch(url, {
128
+ const response = await this.authenticator.authedRealmFetch(url, {
102
129
  headers: {
103
130
  Accept: 'application/vnd.api+json',
104
131
  },
@@ -178,7 +205,7 @@ export abstract class RealmSyncBase {
178
205
  try {
179
206
  const url = `${this.normalizedRealmUrl}_mtimes`;
180
207
 
181
- const response = await this.profileManager.authedRealmFetch(url, {
208
+ const response = await this.authenticator.authedRealmFetch(url, {
182
209
  headers: {
183
210
  Accept: SupportedMimeType.Mtimes,
184
211
  },
@@ -217,7 +244,22 @@ export abstract class RealmSyncBase {
217
244
  }
218
245
  }
219
246
  for (const [fileUrl, mtime] of remoteMtimeEntries) {
220
- const relativePath = fileUrl.replace(this.normalizedRealmUrl, '');
247
+ const rawRelativePath = fileUrl.replace(this.normalizedRealmUrl, '');
248
+ // Realm `_mtimes` keys are URL-encoded (e.g. spaces → %20).
249
+ // The local file listing uses decoded paths
250
+ // (`Knowledge Articles/foo.json`), so leaving the remote
251
+ // form encoded makes the diff treat the encoded and decoded
252
+ // variants as two separate files — sync then "downloads"
253
+ // the remote copy alongside the existing local one and
254
+ // duplicates the workspace.
255
+ let relativePath: string;
256
+ try {
257
+ relativePath = decodeURIComponent(rawRelativePath);
258
+ } catch {
259
+ // Malformed percent escape — fall back to the raw value
260
+ // so a single bad entry doesn't kill the whole sync.
261
+ relativePath = rawRelativePath;
262
+ }
221
263
  if (!this.shouldIgnoreRemoteFile(relativePath)) {
222
264
  mtimes.set(relativePath, mtime);
223
265
  }
@@ -355,7 +397,7 @@ export abstract class RealmSyncBase {
355
397
  const content = await fs.readFile(localPath, 'utf8');
356
398
  const url = this.buildFileUrl(relativePath);
357
399
 
358
- const response = await this.profileManager.authedRealmFetch(url, {
400
+ const response = await this.authenticator.authedRealmFetch(url, {
359
401
  method: 'POST',
360
402
  headers: {
361
403
  'Content-Type': 'text/plain;charset=UTF-8',
@@ -422,8 +464,10 @@ export abstract class RealmSyncBase {
422
464
  }),
423
465
  );
424
466
 
425
- const url = `${this.normalizedRealmUrl}_atomic`;
426
- const response = await this.profileManager.authedRealmFetch(url, {
467
+ const url = this.options.waitForIndex
468
+ ? `${this.normalizedRealmUrl}_atomic?waitForIndex=true`
469
+ : `${this.normalizedRealmUrl}_atomic`;
470
+ const response = await this.authenticator.authedRealmFetch(url, {
427
471
  method: 'POST',
428
472
  headers: {
429
473
  'Content-Type': 'application/vnd.api+json',
@@ -439,9 +483,15 @@ export abstract class RealmSyncBase {
439
483
  const hrefToRelative = new Map(
440
484
  entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
441
485
  );
486
+ // The realm normalizes hrefs: a path with a space goes out as
487
+ // `Knowledge Articles/...` but comes back URL-encoded as
488
+ // `Knowledge%20Articles/...`. Decode the response id before the
489
+ // map lookup so we resolve back to the original relative path
490
+ // instead of falling through to the raw encoded URL.
442
491
  const succeeded = (body['atomic:results'] ?? [])
443
492
  .map((r) => r.data?.id)
444
493
  .filter((id): id is string => typeof id === 'string')
494
+ .map((id) => decodeAtomicResultId(id))
445
495
  .map((id) => hrefToRelative.get(id) ?? id);
446
496
  for (const rel of succeeded) {
447
497
  console.log(` Uploaded: ${rel}`);
@@ -461,7 +511,7 @@ export abstract class RealmSyncBase {
461
511
  const perFile = (errorBody.errors ?? []).map((e) => {
462
512
  const detail = e.detail ?? '';
463
513
  const match = detail.match(/Resource (\S+) /);
464
- const href = match ? match[1] : '';
514
+ const href = match ? decodeAtomicResultId(match[1]) : '';
465
515
  const relMap = new Map(
466
516
  entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
467
517
  );
@@ -495,7 +545,7 @@ export abstract class RealmSyncBase {
495
545
 
496
546
  const url = this.buildFileUrl(relativePath);
497
547
 
498
- const response = await this.profileManager.authedRealmFetch(url, {
548
+ const response = await this.authenticator.authedRealmFetch(url, {
499
549
  headers: {
500
550
  Accept: SupportedMimeType.CardSource,
501
551
  },
@@ -530,13 +580,45 @@ export abstract class RealmSyncBase {
530
580
  }
531
581
 
532
582
  const url = this.buildFileUrl(relativePath);
583
+ const startedAt = Date.now();
533
584
 
534
- const response = await this.profileManager.authedRealmFetch(url, {
535
- method: 'DELETE',
536
- headers: {
537
- Accept: SupportedMimeType.CardSource,
538
- },
539
- });
585
+ let response: Response;
586
+ try {
587
+ response = await this.authenticator.authedRealmFetch(url, {
588
+ method: 'DELETE',
589
+ headers: {
590
+ Accept: SupportedMimeType.CardSource,
591
+ },
592
+ signal: AbortSignal.timeout(DELETE_TIMEOUT_MS),
593
+ });
594
+ } catch (error) {
595
+ let elapsedMs = Date.now() - startedAt;
596
+ console.error(
597
+ ` Delete request failed after ${elapsedMs}ms: ${relativePath}`,
598
+ );
599
+ if (
600
+ error instanceof Error &&
601
+ (error.name === 'TimeoutError' || error.name === 'AbortError')
602
+ ) {
603
+ let deleteApplied = await this.verifyDeleteApplied(relativePath);
604
+ if (deleteApplied === true) {
605
+ console.warn(
606
+ ` Delete response timed out after ${DELETE_TIMEOUT_MS}ms, but ${relativePath} is already gone on the realm; continuing`,
607
+ );
608
+ return;
609
+ }
610
+ throw new Error(
611
+ `Timed out deleting ${relativePath} after ${DELETE_TIMEOUT_MS}ms`,
612
+ { cause: error },
613
+ );
614
+ }
615
+ throw error;
616
+ }
617
+
618
+ let elapsedMs = Date.now() - startedAt;
619
+ console.log(
620
+ ` Delete response for ${relativePath}: ${response.status} ${response.statusText} (${elapsedMs}ms)`,
621
+ );
540
622
 
541
623
  if (!response.ok && response.status !== 404) {
542
624
  throw new Error(
@@ -547,6 +629,30 @@ export abstract class RealmSyncBase {
547
629
  console.log(` Deleted: ${relativePath}`);
548
630
  }
549
631
 
632
+ private async verifyDeleteApplied(
633
+ relativePath: string,
634
+ ): Promise<boolean | 'unknown'> {
635
+ const url = this.buildFileUrl(relativePath);
636
+ try {
637
+ const response = await this.authenticator.authedRealmFetch(url, {
638
+ headers: {
639
+ Accept: SupportedMimeType.CardSource,
640
+ },
641
+ signal: AbortSignal.timeout(DELETE_TIMEOUT_PROBE_MS),
642
+ });
643
+ console.warn(
644
+ ` Delete-timeout probe for ${relativePath}: ${response.status} ${response.statusText}`,
645
+ );
646
+ return response.status === 404 ? true : false;
647
+ } catch (probeError) {
648
+ console.warn(
649
+ ` Delete-timeout probe failed for ${relativePath}:`,
650
+ probeError,
651
+ );
652
+ return 'unknown';
653
+ }
654
+ }
655
+
550
656
  protected async deleteLocalFile(localPath: string): Promise<void> {
551
657
  console.log(`Deleting local: ${localPath}`);
552
658