Skip to content
Merged
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
18 changes: 18 additions & 0 deletions packages/boxel-cli/plugin/skills/realm-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ A profile must be active. If `boxel profile list` shows none, the user has to ru

`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 reruns watch with `--overwrite-local` to accept the remote.

## Pushing without realm events (`--no-realm-events`)

Every write to a realm normally broadcasts realm events to each session room subscribed to it. Open tabs use them to show that the realm is indexing, then reload the changed cards, modules and live searches. `push --no-realm-events` marks each write it sends so the realm server announces none of them. The files are still written and indexed, and the server's caches are still invalidated. Open tabs just aren't told, so they pick the new content up on their next page load.

**Use it** when a push replaces a realm's content out of band, for a realm people mostly read rather than edit:

- A deploy that pushes a git tree to a shared realm, such as the catalog. Without the flag, every open tab on that realm reloads code its user didn't ask for, mid-session, and the realm server fans the events out to every subscribed room.
- Seeding or restoring a realm in bulk when nobody needs to see the change live.

**Don't use it** when someone needs to see the push as it lands:

- Pushing your own edits to a workspace you, a collaborator or an agent has open. Those tabs keep showing the old cards and code until reload. Card saves don't check for a newer version, so an open tab's next save of a stale card writes the old content back over the push.
- Small, everyday pushes. Their events cost little, and live refresh is what users expect.

When unsure, leave it off. Realm servers that predate the flag ignore it and announce as usual.

## Realm skills reach Claude Code automatically

A workspace's user-authored skills live in the realm as `skills/<name>/SKILL.md` (see `/boxel-skills:boxel-skill-authoring`). `pull`, `sync`, and `watch` expose them to Claude Code with no extra step: each one is copied into `<local-dir>/.claude/skills/<realm>-<name>/`, where `<realm>` is the last segment of the realm URL. So a skill named `trip-planner` in `…/alice/experiments/` becomes `/experiments-trip-planner` in the next session.
Expand Down Expand Up @@ -118,6 +134,7 @@ Push local files to a Boxel realm
- `--dry-run` — Show what would be done without making changes
- `--force` — Upload all files, even if unchanged
- `--changed-since <revision>` — Upload only files that differ from this git revision (one the realm already holds), plus files git does not track and files missing from the realm, instead of comparing against the sync manifest. Repeat it to name every revision the realm may hold part of, such as one a failed sync stopped partway through; a file is skipped only when it matches all of them. A file edited directly on the realm stays as it is until it changes in git. The local directory must be a git checkout whose history reaches each revision
- `--no-realm-events` — Index the pushed changes without announcing them to open tabs, which pick them up on their next load (for deploys)
- `--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)

### `boxel realm pull <realm-url> <local-dir>`
Expand Down Expand Up @@ -179,5 +196,6 @@ List realms accessible to the active profile
## Pitfalls

- `boxel realm push --delete` will remove remote files that don't exist locally. Confirm the user intends destructive sync before adding the flag.
- Don't add `--no-realm-events` to a push into a realm someone has open. Their tabs go stale, and a stale tab's next save can overwrite the push. See [Pushing without realm events](#pushing-without-realm-events---no-realm-events).
- The `<realm-url>` argument is the realm's _base URL_ (`https://app.boxel.ai/owner/realm/`), not a card URL inside the realm. If the user pastes a card URL, strip the path back to the realm root.
- Newly created realms need indexing before they're usable. After `boxel realm create`, follow up with `boxel realm wait-for-ready` (see `/boxel-cli:realm-history`).
12 changes: 12 additions & 0 deletions packages/boxel-cli/src/commands/realm/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,11 @@ export interface PushCommandOptions {
delete?: boolean;
dryRun?: boolean;
force?: boolean;
/**
* False to push without the realm announcing the changes to its
* subscribers (see `SyncOptions.realmEvents`).
*/
realmEvents?: boolean;
atomicBatchSize?: number;
/**
* Git revisions the realm's files may hold. Only files that differ from any
Expand Down Expand Up @@ -464,6 +469,10 @@ export function registerPushCommand(realm: Command): void {
'Upload only files that differ from this git revision (one the realm already holds), plus files git does not track and files missing from the realm, instead of comparing against the sync manifest. Repeat it to name every revision the realm may hold part of, such as one a failed sync stopped partway through; a file is skipped only when it matches all of them. A file edited directly on the realm stays as it is until it changes in git. The local directory must be a git checkout whose history reaches each revision',
(revision: string, previous: string[] = []) => [...previous, revision],
)
.option(
'--no-realm-events',
'Index the pushed changes without announcing them to open tabs, which pick them up on their next load (for deploys)',
)
.option(
'--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)',
Expand All @@ -476,6 +485,7 @@ export function registerPushCommand(realm: Command): void {
delete?: boolean;
dryRun?: boolean;
force?: boolean;
realmEvents?: boolean;
changedSince?: string[];
realmSecretSeed?: boolean;
},
Expand All @@ -487,6 +497,7 @@ export function registerPushCommand(realm: Command): void {
delete: options.delete,
dryRun: options.dryRun,
force: options.force,
realmEvents: options.realmEvents,
changedSince: options.changedSince,
realmSecretSeed,
});
Expand Down Expand Up @@ -542,6 +553,7 @@ export async function pushCommand(
deleteRemote: options.delete,
dryRun: options.dryRun,
force: options.force,
realmEvents: options.realmEvents,
atomicBatchSize: options.atomicBatchSize,
changedSince,
},
Expand Down
34 changes: 26 additions & 8 deletions packages/boxel-cli/src/lib/realm-sync-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ export interface SyncOptions {
* default.
*/
waitForIndex?: boolean;
/**
* When false, every write this sync sends carries
* `X-Boxel-Realm-Events: none`, so the realm indexes the changes without
* announcing them to its subscribers. Open tabs pick the new content up on
* their next load instead of resetting mid-session. For pushes that replace
* a realm's content wholesale, such as a deploy. On by default.
*/
realmEvents?: boolean;
/** Files per /_atomic request. Defaults to ATOMIC_BATCH_SIZE. */
atomicBatchSize?: number;
}
Expand Down Expand Up @@ -158,6 +166,16 @@ export abstract class RealmSyncBase {
return `${this.normalizedRealmUrl}${cleanDir}/`;
}

// Headers for a request that writes or removes a file, with the realm-event
// opt-out added when this sync asked for it.
protected writeHeaders(
headers: Record<string, string>,
): Record<string, string> {
return this.options.realmEvents === false
? { ...headers, 'X-Boxel-Realm-Events': 'none' }
: headers;
}

protected buildFileUrl(relativePath: string): string {
const cleanPath = relativePath.replace(/^\/+/, '');
return `${this.normalizedRealmUrl}${cleanPath}`;
Expand Down Expand Up @@ -451,10 +469,10 @@ export abstract class RealmSyncBase {

const response = await this.authenticator.authedRealmFetch(url, {
method: 'POST',
headers: {
headers: this.writeHeaders({
'Content-Type': 'text/plain;charset=UTF-8',
Accept: SupportedMimeType.CardSource,
},
}),
body: content,
});

Expand All @@ -481,9 +499,9 @@ export abstract class RealmSyncBase {

const response = await this.authenticator.authedRealmFetch(url, {
method: 'POST',
headers: {
headers: this.writeHeaders({
'Content-Type': SupportedMimeType.OctetStream,
},
}),
body: bytes,
});

Expand Down Expand Up @@ -724,10 +742,10 @@ export abstract class RealmSyncBase {
try {
response = await this.authenticator.authedRealmFetch(url, {
method: 'POST',
headers: {
headers: this.writeHeaders({
'Content-Type': 'application/vnd.api+json',
Accept: 'application/vnd.api+json',
},
}),
body: JSON.stringify({ 'atomic:operations': operations }),
});
} catch (err) {
Expand Down Expand Up @@ -874,9 +892,9 @@ export abstract class RealmSyncBase {
try {
response = await this.authenticator.authedRealmFetch(url, {
method: 'DELETE',
headers: {
headers: this.writeHeaders({
Accept: SupportedMimeType.CardSource,
},
}),
signal: AbortSignal.timeout(DELETE_TIMEOUT_MS),
});
} catch (error) {
Expand Down
54 changes: 54 additions & 0 deletions packages/boxel-cli/tests/integration/realm-push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,60 @@ describe('realm push (integration)', () => {
expect(await remoteFileExists(realmUrl, 'c.gts')).toBe(true);
});

it('--no-realm-events marks every write it sends, and a plain push marks none', async () => {
// In-process so the requests the push sends can be inspected.
let realmUrl = await createTestRealm();
let localDir = makeLocalDir();
writeLocalFile(localDir, 'a.gts', 'export const a = 1;\n');
writeLocalBytes(localDir, 'pixel.png', TINY_PNG_BYTES);
writeLocalFile(localDir, 'stale.gts', 'export const stale = 1;\n');

let profileManager = reloadProfile(home);
let realFetch = profileManager.authedRealmFetch.bind(profileManager);
let writes: { method: string; marker: string | null }[] = [];
let fetchSpy = vi
.spyOn(profileManager, 'authedRealmFetch')
.mockImplementation(async (input, init) => {
let method = init?.method ?? 'GET';
if (method === 'POST' || method === 'DELETE') {
writes.push({
method,
marker: new Headers(init?.headers).get('X-Boxel-Realm-Events'),
});
}
return realFetch(input, init);
});

try {
await pushCommand(localDir, realmUrl, { profileManager });
expect(writes.length).toBeGreaterThan(0);
expect(writes.every(({ marker }) => marker === null)).toBe(true);

writes = [];
fs.rmSync(path.join(localDir, 'stale.gts'));
writeLocalFile(localDir, 'a.gts', 'export const a = 2;\n');
await pushCommand(localDir, realmUrl, {
profileManager,
delete: true,
force: true,
realmEvents: false,
});
// The upload batch, the binary upload and the removal of the file
// that no longer exists locally.
expect(writes.map(({ method }) => method).sort()).toEqual([
'DELETE',
'POST',
'POST',
]);
expect(writes.every(({ marker }) => marker === 'none')).toBe(true);
} finally {
fetchSpy.mockRestore();
}

expect(await fetchRemoteFile(realmUrl, 'a.gts')).toContain('a = 2');
expect(await remoteFileExists(realmUrl, 'stale.gts')).toBe(false);
});

it('splits an upload into /_atomic requests, modules first', async () => {
// In-process so the /_atomic requests can be read.
let realmUrl = await createTestRealm();
Expand Down
2 changes: 1 addition & 1 deletion packages/realm-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,7 @@ export class RealmServer {
// here the preflight fails and no browser client can invoke a card
// operation cross-origin at all.
allowHeaders:
'Accept, Authorization, Content-Type, If-Match, If-None-Match, If-Range, Range, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename, X-Boxel-During-Prerender, X-Boxel-Skip-Index-Wait, X-Boxel-Consuming-Realm, X-Boxel-Job-Id, X-Boxel-Job-Priority, X-Boxel-Logging-Correlation-Id, X-Boxel-Link-Shape, X-Grafana-Device-Id, X-Grafana-Action',
'Accept, Authorization, Content-Type, If-Match, If-None-Match, If-Range, Range, X-Requested-With, X-Boxel-Client-Request-Id, X-Boxel-Assume-User, X-HTTP-Method-Override, X-Boxel-Disable-Module-Cache, X-Filename, X-Boxel-During-Prerender, X-Boxel-Skip-Index-Wait, X-Boxel-Realm-Events, X-Boxel-Consuming-Realm, X-Boxel-Job-Id, X-Boxel-Job-Priority, X-Boxel-Logging-Correlation-Id, X-Boxel-Link-Shape, X-Grafana-Device-Id, X-Grafana-Action',
// Without an explicit expose list, @koa/cors only emits the
// CORS-safelisted response headers (cache-control, content-*,
// expires, last-modified, pragma). ETag is not on that list,
Expand Down
Loading
Loading