Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/boxel-cli/plugin/skills/realm-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Wraps the `boxel realm` subcommands that move data between a local directory and
- **`push`** — local → remote. Deploy local edits to the realm.
- **`pull`** — remote → local. Download a realm into a directory.
- **`sync`** — bidirectional. Reconcile both sides; needs a `--prefer-*` flag when there are conflicts.
- **`watch`** — remote → local, continuous. Long-running poller; pulls remote changes into the local directory as they happen.
- **`watch`** — remote → local, continuous. Long-running poller; pulls remote changes into the local directory as they happen. Locally-edited files are *not* overwritten by default — the watcher skips downloads when the local copy diverges from the sync manifest, logs a warning, and keeps polling. Pass `--overwrite-local` to opt back into the unconditional mirror behavior.
- **`create`** — provision a new realm on the realm server.
- **`remove`** — delete a realm and unlink it from the active profile.
- **`list`** — see realms the active profile can access.
Expand Down Expand Up @@ -43,6 +43,10 @@ A profile must be active. If `boxel profile list` shows none, the user has to ru

`push` and `pull` have their own `--delete` and `--dry-run` flags but no `--prefer-*` flags (they're one-directional). When in doubt, dry-run first.

`watch` protects local edits without a flag: by default any file whose local hash differs from the sync manifest is skipped (with a yellow `⚠ skipped …` line) instead of overwritten. The warning re-fires on every poll until the user reconciles via `boxel realm sync …` (e.g. `--prefer-newest`) or rerun watch with `--overwrite-local` to accept the remote.

If `watch` is starting in a directory that already mirrors the realm but has no `.boxel-sync.json` (e.g. populated by hand, by `git clone`, or by a different tool), run `boxel realm pull` first. Without a manifest every existing file looks "diverged" and the first poll warns about each one until reconciled.

<!-- generated:commands:start -->

## Commands
Expand Down Expand Up @@ -81,6 +85,7 @@ Start watching a Boxel realm for server-side changes and pull them into a local
- `-i, --interval <seconds>` — Polling interval in seconds
- `-d, --debounce <seconds>` — Seconds to wait after a burst of changes before applying them
- `--realm-secret-seed` — Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)
- `--overwrite-local` — Overwrite local files when the remote changes. Default: skip + warn when the local copy diverges from the sync manifest.

### `boxel realm watch stop`

Expand Down
107 changes: 90 additions & 17 deletions packages/boxel-cli/src/commands/realm/watch/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ interface PendingChange {
export interface FlushResult {
pulled: string[];
deleted: string[];
/**
* Files whose remote-side change was detected but not applied because the
* local copy diverges from the sync manifest. Cleared by passing
* `overwriteLocal: true`, or by reconciling via `boxel realm sync`.
*/
skipped: string[];
checkpoint: Checkpoint | null;
}

Expand All @@ -59,6 +65,7 @@ export interface FlushResult {
export class RealmWatcher extends RealmSyncBase {
readonly name: string;
private readonly debounceMs: number;
private readonly overwriteLocal: boolean;
private readonly checkpointManager: CheckpointManager;
private lastKnownMtimes = new Map<string, number>();
private pendingChanges = new Map<string, PendingChange>();
Expand All @@ -68,10 +75,11 @@ export class RealmWatcher extends RealmSyncBase {
constructor(
spec: WatchRealmSpec,
authenticator: RealmAuthenticator,
options: { debounceMs: number },
options: { debounceMs: number; overwriteLocal?: boolean },
) {
super({ realmUrl: spec.realmUrl, localDir: spec.localDir }, authenticator);
this.debounceMs = options.debounceMs;
this.overwriteLocal = options.overwriteLocal ?? false;
this.checkpointManager = new CheckpointManager(spec.localDir);
this.name = deriveRealmName(this.normalizedRealmUrl);
}
Expand Down Expand Up @@ -186,7 +194,7 @@ export class RealmWatcher extends RealmSyncBase {
}

if (this.pendingChanges.size === 0) {
return { pulled: [], deleted: [], checkpoint: null };
return { pulled: [], deleted: [], skipped: [], checkpoint: null };
}

// Snapshot then clear before any await — anything an interleaved poll()
Expand All @@ -197,11 +205,27 @@ export class RealmWatcher extends RealmSyncBase {

const pulled: string[] = [];
const deleted: string[] = [];
const skipped: string[] = [];
const changes: CheckpointChange[] = [];

// Load the manifest once per flush so we hash-compare against a single
// baseline. Skipped when `overwriteLocal` is on — we never look.
const manifest = this.overwriteLocal
? null
: await loadManifest(this.options.localDir);
Comment on lines +213 to +215
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ignore manifests from other realms before divergence checks

Treating any .boxel-sync.json as the divergence baseline can silently bypass the new local-edit protection when the manifest belongs to a different realm. In that case, localDivergesFromManifest may see a matching hash and allow overwrite/delete, even though this watch session has no valid sync baseline for this realm. initialize() already guards remoteMtimes with a realm URL match, so flushPending() should similarly discard mismatched manifests (or pass null) before hash comparisons to avoid unintended local data loss.

Useful? React with 👍 / 👎.


for (const [file, info] of drained) {
const localPath = path.join(this.options.localDir, file);

if (
!this.overwriteLocal &&
(await this.localDivergesFromManifest(localPath, file, manifest))
) {
skipped.push(file);
continue;
}

if (info.status === 'deleted') {
const localPath = path.join(this.options.localDir, file);
try {
await fs.unlink(localPath);
} catch (err: any) {
Expand All @@ -210,29 +234,58 @@ export class RealmWatcher extends RealmSyncBase {
deleted.push(file);
changes.push({ file, status: 'deleted' });
} else {
const localPath = path.join(this.options.localDir, file);
await this.downloadFile(file, localPath);
pulled.push(file);
changes.push({ file, status: info.status });
}
}

// Only advance mtimes for files we actually applied. Skipped entries
// keep their old `lastKnownMtimes` value (or absence) so the next poll
// re-detects them — the warning persists until reconciled.
const skippedSet = new Set(skipped);
for (const [file, info] of drained) {
if (skippedSet.has(file)) continue;
if (info.status === 'deleted') {
this.lastKnownMtimes.delete(file);
} else {
this.lastKnownMtimes.set(file, info.mtime);
}
}

await this.persistManifest(pulled, deleted);
let checkpoint: Checkpoint | null = null;
if (changes.length > 0) {
await this.persistManifest(pulled, deleted);
checkpoint = await this.checkpointManager.createCheckpoint(
'remote',
changes,
);
}

const checkpoint = await this.checkpointManager.createCheckpoint(
'remote',
changes,
);
return { pulled, deleted, skipped, checkpoint };
}

return { pulled, deleted, checkpoint };
/**
* True when a local file exists at `localPath` but its content no longer
* matches the hash recorded for `relPath` in the sync manifest (or the
* manifest has no record of it at all). False when no local file exists
* — there's nothing to protect.
*/
private async localDivergesFromManifest(
localPath: string,
relPath: string,
manifest: SyncManifest | null,
): Promise<boolean> {
let localHash: string;
try {
localHash = await computeFileHash(localPath);
} catch (err: any) {
if (err.code === 'ENOENT') return false;
throw err;
}
const manifestHash = manifest?.files[relPath];
if (manifestHash === undefined) return true;
return localHash !== manifestHash;
}

/**
Expand Down Expand Up @@ -332,6 +385,12 @@ export interface WatchRealmsOptions {
authenticator?: RealmAuthenticator;
/** Stops the watch loop when aborted. SIGINT/SIGTERM are wired up when omitted. */
signal?: AbortSignal;
/**
* When true, downloads always overwrite the local file. When false
* (default), files whose local copy diverges from the sync manifest are
* skipped with a warning instead of overwritten.
*/
overwriteLocal?: boolean;
}

export interface WatchRealmsResult {
Expand All @@ -358,6 +417,7 @@ export async function watchRealms(
const intervalMs = options.intervalMs ?? 30_000;
const debounceMs = options.debounceMs ?? 5_000;
const quiet = options.quiet ?? false;
const overwriteLocal = options.overwriteLocal ?? false;

if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
return { watchers: [], error: '`intervalMs` must be a positive number.' };
Expand Down Expand Up @@ -408,6 +468,7 @@ export async function watchRealms(
for (const spec of specs) {
const watcher = new RealmWatcher(spec, authenticator, {
debounceMs,
overwriteLocal,
});
try {
await watcher.initialize();
Expand Down Expand Up @@ -546,14 +607,20 @@ function formatLockedError(localDir: string, info: WatchLockInfo): string {

function logFlush(name: string, result: FlushResult): void {
const total = result.pulled.length + result.deleted.length;
if (total === 0) return;
console.log(
`${DIM}[${timestamp()}]${RESET} [${name}] ${FG_GREEN}applied ${total} change(s)${RESET} (${result.pulled.length} pulled, ${result.deleted.length} deleted)`,
);
if (result.checkpoint) {
const tag = result.checkpoint.isMajor ? '[MAJOR]' : '[minor]';
if (total > 0) {
console.log(
` ${DIM}Checkpoint:${RESET} ${result.checkpoint.shortHash} ${tag} ${result.checkpoint.message}`,
`${DIM}[${timestamp()}]${RESET} [${name}] ${FG_GREEN}applied ${total} change(s)${RESET} (${result.pulled.length} pulled, ${result.deleted.length} deleted)`,
);
if (result.checkpoint) {
const tag = result.checkpoint.isMajor ? '[MAJOR]' : '[minor]';
console.log(
` ${DIM}Checkpoint:${RESET} ${result.checkpoint.shortHash} ${tag} ${result.checkpoint.message}`,
);
}
}
for (const file of result.skipped) {
console.log(
`${DIM}[${timestamp()}]${RESET} [${name}] ${FG_YELLOW}⚠ skipped ${file}: local diverges from sync manifest (rerun with --overwrite-local to discard, or \`boxel realm sync\` to reconcile)${RESET}`,
);
}
}
Expand Down Expand Up @@ -614,6 +681,10 @@ export function registerStartCommand(watch: Command): void {
'--realm-secret-seed',
'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
)
.option(
'--overwrite-local',
'Overwrite local files when the remote changes. Default: skip + warn when the local copy diverges from the sync manifest.',
)
.action(
async (
realmUrl: string,
Expand All @@ -622,6 +693,7 @@ export function registerStartCommand(watch: Command): void {
interval: number;
debounce: number;
realmSecretSeed?: boolean;
overwriteLocal?: boolean;
},
) => {
const realmSecretSeed = await resolveRealmSecretSeed(
Expand All @@ -631,6 +703,7 @@ export function registerStartCommand(watch: Command): void {
intervalMs: options.interval * 1000,
debounceMs: options.debounce * 1000,
realmSecretSeed,
overwriteLocal: options.overwriteLocal === true,
});
if (result.error) {
console.error(`${FG_RED}Error:${RESET} ${result.error}`);
Expand Down
Loading
Loading