Skip to content
This repository was archived by the owner on Jun 4, 2026. It is now read-only.
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
12 changes: 9 additions & 3 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,17 @@ boxel sync . --prefer-newest # Keep newest version
boxel sync . --delete # Sync deletions both ways
boxel sync . --dry-run # Preview only

boxel push ./local <url> # One-way push (local → remote)
boxel push ./local <url> --delete # Push and remove orphaned remote files
boxel pull <url> ./local # One-way pull (remote → local)
boxel push ./local <url> # One-way push (local → remote)
boxel push ./local <url> --delete # Push and remove orphaned remote files
boxel push ./local <url> --batch # Atomic batch upload (10/batch default)
boxel push ./local <url> --batch --batch-size 25 # Custom batch size
boxel pull <url> ./local # One-way pull (remote → local)
```

> **Pull writes a manifest:** After `boxel pull <url> ./local` downloads files, it automatically writes `.boxel-sync.json` so `boxel sync .` works immediately against the fresh directory. No manual step needed between pull and first sync.

> **`push --batch`:** `.gts` definitions upload individually in dependency order; `.json` instances batch through `/_atomic` in groups of N. Faster for bulk pushes (50+ files). Binary files (images, fonts) and plain-text files (`.md`, `.csv`, `.yaml`) always take the per-file POST path because `/_atomic` only accepts card and source resource types.

**Failed download cleanup:** When `sync` encounters files that return 500 errors (broken/corrupted on server), it will prompt you to delete them:
```
⚠️ 3 file(s) failed to download (server error):
Expand Down
37 changes: 37 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ dist/
# Synced workspaces (never commit workspace content)
stack.cards/
boxel.ai/
boxel-workspaces/
down/
down-*/
up/
.boxel-sync.json
.boxel-history/

Expand Down Expand Up @@ -41,3 +45,36 @@ npm-debug.log*

# Worktrees
.claude/worktrees
.gstack/

# Runtime locks
.claude/scheduled_tasks.lock

# ─── Drift guards ──────────────────────────────────────────────────────────
# Content that historically leaked into this repo while working in Boxel
# workspaces from a CWD inside boxel-cli. These belong to boxel's realm-server,
# host, or are workspace content — never boxel-cli source. Fail closed: if
# any of these reappear, they stay untracked instead of silently committed.

# Design docs about Boxel platform (not CLI)
/docs/yjs-*.md
/docs/realm-*.md
/docs/*collaboration*.md
/docs/boxel-package-*.md
/docs/catalog-*.md
/docs/card-field-*.md
/docs/cross-realm-*.md
/docs/llm-wiki/

# Data-generation scripts for realms (belong with the workspace they feed)
/scripts/northwind*
/scripts/generate-*
/scripts/fetch-northwind.mjs
/scripts/northwind-cache/

# JQXL engine tests (the engine lives in realm-server, not here)
/test/helpers/jqxl-*
/test/lib/jqxl-*

# Misplaced Claude skills (add legit skills to .claude/commands/ by hand)
/.claude/commands/extract-theme.md
27 changes: 25 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Trigger examples:
- Working in a synced workspace (`.boxel-sync.json` present)

## Core Command Semantics
- `pull`: remote -> local
- `pull`: remote -> local (also writes `.boxel-sync.json` so `sync` works immediately)
- `push`: local -> remote
- `sync`: bidirectional conflict resolution
- `track`: local file watching with auto-checkpoints (use `--push` for real-time server sync)
Expand Down Expand Up @@ -112,11 +112,34 @@ boxel gather . -s /path/to/repo

## Batch Upload API
The CLI supports batch uploads via the `/_atomic` endpoint:
- Used by `track --push` for efficient multi-file uploads
- Used by `track --push` and `push --batch` for efficient multi-file uploads
- Sorts definitions (.gts) before instances (.json) for proper indexing
- Fallback strategy: full batch → smaller batches → individual uploads
- See `src/lib/batch-upload.ts` for implementation

### Content-type routing (since 1.0.1)
Before sending bytes anywhere, the uploader decides which path a file takes based on extension (see `src/lib/content-type.ts`):

| File class | Examples | Path | Content-Type | Accept |
|---|---|---|---|---|
| Compilable source | `.gts`, `.ts`, `.tsx`, `.js`, `.jsx`, `.cjs`, `.mjs`, `.css`, `.scss`, `.less`, `.sass`, `.html` | `/_atomic` (type: `source`) | per-extension MIME | `application/vnd.card+source` |
| Card JSON | `.json` | `/_atomic` (type: `card`, fallback `source` on parse failure) | `application/json` | `application/vnd.card+source` |
| Plain text, non-source | `.md`, `.txt`, `.csv`, `.yaml`, `.xml` | per-file POST | per-extension MIME | `*/*` |
| Binary | `.png`, `.jpg`, `.woff`, `.pdf`, `.zip`, etc. | per-file POST | per-extension MIME or `application/octet-stream` | `*/*` |

Rationale: `/_atomic` rejects anything its module compiler can't parse. Plain text and binary files need their raw bytes stored directly, which only the per-file POST endpoint does correctly.

### Manifest shape (since 1.0.1)
All three sync commands agree on one `.boxel-sync.json` shape:
```ts
interface SyncManifest {
workspaceUrl: string;
lastSyncTime?: number;
files: Record<string, { localHash: string; remoteMtime: number }>;
}
```
`push.ts` migrates the pre-1.0.1 format (`files[path] = hashString`) on read. New writes always use the object form. Mirror this shape if adding a new command that touches the manifest.

## Notes for Agents Editing This Repo
- Prefer minimal, targeted command changes in `src/commands/*.ts`.
- Validate with local build/tests when feasible.
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Changelog

All notable changes to `boxel-cli`. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/spec/v2.0.0.html).

## 1.0.1 — 2026-04-20

### New

- `boxel push --batch [--batch-size N]` — atomic bulk upload. Definitions upload individually in dependency order (so FieldDefs land before CardDefs that contain them); instances batch through `/_atomic` in groups of N (default 10). Faster and quieter than per-file POST on pushes of 50+ files, and reduces UI re-indexing churn.
- `boxel pull <url> ./local` writes `.boxel-sync.json` automatically after a fresh download. You can now run `boxel sync .` immediately against a just-pulled directory with no manual intermediate step.

### Fixed

- **Binary upload corruption.** Images, fonts, PDFs, and other non-text files were being routed through the `/_atomic` JSON endpoint with text encoding, corrupting the bytes. Binary files now take the per-file POST path with `application/octet-stream`.
- **Plain-text file rejection.** `.md`, `.csv`, `.yaml`, `.xml`, and `.txt` uploads were being rejected by the realm's module compiler as "invalid source". Plain-text files now take the per-file POST path with their true MIME type.
- **Manifest shape drift between push and pull.** `push` and `pull` had diverged on the shape of `.boxel-sync.json`. Mixed-command workflows (pull → push or pull → sync → push) could mark every file as changed on the next run. All three commands now use one canonical shape; `push` migrates the pre-1.0.1 bare-string format on read.
- **Partial-failure batch marks files as synced.** In `--batch` mode, the manifest was updated for every file in a batch whenever any file succeeded, even if some of them had failed. Failed uploads could be silently stranded without retry. The manifest now tracks only files that successfully uploaded; failures stay out and get retried on the next run.
- **`boxel --version` reported wrong number.** The CLI had a hardcoded version string that drifted from `package.json`. Version is now sourced from `package.json` at runtime.
- **`--batch-size` silently accepted garbage.** `--batch-size abc` or `--batch-size -5` used to flow through as `NaN` / negative and cause weird behavior downstream. Non-positive-integer input now fails fast with a clear error.

### For contributors

- New `src/lib/content-type.ts` — single source of truth mapping file extension → MIME type → upload-path decision. Any extension you add for atomic-compatibility should also go here.
- New drift-guards section in `.gitignore` — prevents Boxel platform docs, workspace dirs, and other content that commonly ends up at the repo root from leaking into commits.
- `AGENTS.md` now documents the content-type routing table (file class → path → headers) and the canonical manifest shape, so future additions to `batch-upload.ts` or any manifest-touching command have one reference.

---

## 1.0.0 — 2026-02-13

Initial public release. Core sync, push, pull, watch, track, history, profiles, multi-realm config, realm repair, share/gather GitHub workflow, skill-based Claude Code integration.
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,17 @@ boxel sync . --prefer-newest # Keep newest by timestamp
boxel sync . --delete # Sync deletions both ways
boxel sync . --dry-run # Preview only

boxel push ./local <url> # One-way push (local → remote)
boxel push ./local <url> --delete # Push and remove orphaned remote files
boxel pull <url> ./local # One-way pull (remote → local)
boxel push ./local <url> # One-way push (local → remote)
boxel push ./local <url> --delete # Push and remove orphaned remote files
boxel push ./local <url> --batch # Atomic batch upload (10 files per batch)
boxel push ./local <url> --batch --batch-size 25 # Custom batch size
boxel pull <url> ./local # One-way pull (remote → local)
```

> **Note:** `boxel pull` writes `.boxel-sync.json` automatically after a fresh download, so you can run `boxel sync .` immediately against a freshly-pulled workspace with no extra setup.

> **`--batch` mode** (push only): `.gts` definitions upload individually in dependency order, then `.json` instances batch through the server's `/_atomic` endpoint in groups of N. Meaningfully faster on big pushes (50+ files) and reduces UI flashing during server re-indexing. Binary files (images, fonts) and plain-text files (`.md`, `.csv`, `.yaml`) are routed to per-file POST regardless of mode, because `/_atomic` only accepts card and source resource types.

**Failed download cleanup:** When `sync` encounters files that return 500 errors (broken on server), it will prompt you to delete them:
```
⚠️ 3 file(s) failed to download (server error):
Expand Down Expand Up @@ -665,12 +671,19 @@ When you open this repo in Claude Code, it will guide you through setup and prov

---

## Release notes

See [CHANGELOG.md](CHANGELOG.md) for per-version changes.

---

## Contributing

PRs welcome! Please ensure:
- Code passes linting (`npm run lint`)
- New features have documentation
- Breaking changes are noted in PR description
- Add a bullet to `CHANGELOG.md` under the in-progress version (or start a new `## Unreleased` section if one doesn't exist)

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "boxel-cli",
"version": "1.0.0",
"version": "1.0.1",
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

package.json version was bumped to 1.0.1, but the CLI still reports 1.0.0 via program.version('1.0.0') in src/index.ts. Consider sourcing the version from package.json (or updating the hardcoded string) so boxel --version matches the published package version.

Copilot uses AI. Check for mistakes.
"description": "CLI for bidirectional sync between local directories and Boxel workspaces",
"type": "module",
"main": "dist/index.js",
Expand Down
4 changes: 2 additions & 2 deletions src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface SyncManifest {
files: Record<string, { localHash: string; remoteMtime: number }>;
}

function computeFileHash(content: string): string {
function computeFileHash(content: string | Buffer): string {
return crypto.createHash('md5').update(content).digest('hex');
}

Expand Down Expand Up @@ -72,7 +72,7 @@ export async function checkCommand(
const relativePath = path.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');

// Read local file
const localContent = fs.readFileSync(absolutePath, 'utf-8');
const localContent = fs.readFileSync(absolutePath);
const localHash = computeFileHash(localContent);
const localMtime = fs.statSync(absolutePath).mtimeMs;

Expand Down
35 changes: 35 additions & 0 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { RealmSyncBase, validateMatrixEnvVars, type SyncOptions } from '../lib/r
import { CheckpointManager, type CheckpointChange } from '../lib/checkpoint-manager.js';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';

interface SyncManifest {
workspaceUrl: string;
lastSyncTime: number;
files: Record<string, { localHash: string; remoteMtime: number }>;
}

function computeFileHash(filePath: string): string {
const content = fs.readFileSync(filePath);
return crypto.createHash('md5').update(content).digest('hex');
}

interface PullOptions extends SyncOptions {
deleteLocal?: boolean;
Expand Down Expand Up @@ -104,6 +116,29 @@ class RealmPuller extends RealmSyncBase {
}
}

// Create sync manifest so subsequent `boxel sync` knows files are in sync
if (!this.options.dryRun && downloadedFiles.length > 0) {
const remoteMtimes = await this.getRemoteMtimes();
const manifest: SyncManifest = {
workspaceUrl: this.options.workspaceUrl,
lastSyncTime: Date.now(),
files: {},
};

for (const relativePath of downloadedFiles) {
const localPath = path.join(this.options.localDir, relativePath);
if (fs.existsSync(localPath)) {
manifest.files[relativePath] = {
localHash: computeFileHash(localPath),
remoteMtime: remoteMtimes.get(relativePath) || Math.floor(Date.now() / 1000),
};
}
}

const manifestPath = path.join(this.options.localDir, '.boxel-sync.json');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
}
Comment on lines +119 to +140
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

boxel pull now writes .boxel-sync.json in the new sync-manifest shape (files[relPath] = { localHash, remoteMtime }), but push still expects the old push-only manifest format (files[relPath] = string). This will cause push to treat every file as changed (and/or overwrite the manifest back to the old format). Align manifest reading/writing across commands (or add migration logic in push similar to sync.ts).

Copilot uses AI. Check for mistakes.

// Create checkpoint for pulled files
if (!this.options.dryRun && downloadedFiles.length > 0) {
const checkpointManager = new CheckpointManager(this.options.localDir);
Expand Down
Loading