Skip to content
This repository was archived by the owner on Jun 4, 2026. It is now read-only.

Commit 57385f5

Browse files
Chrisclaude
authored andcommitted
fix: address PR #17 Copilot review
Six fixes in one commit, addressing every Copilot comment except #7 (dead code, deferred to a later cleanup pass). #4 — manifest shape mismatch (critical): push.ts was still reading/writing the old manifest format (files[path] = hashString) while pull.ts and sync.ts use the new {localHash, remoteMtime} shape. push now uses the new shape, migrates old manifests on read (mirrors sync.ts detector), and refreshes remoteMtime via getRemoteMtimes() after a successful upload so the next pull/sync sees a consistent picture. #3 — partial-success manifest (important): In --batch mode we were marking every instance as synced when result.uploaded > 0, even if some failed. Now we derive failed paths from result.errors and only add successes to the manifest. Failed uploads stay out and get retried on the next run. #6 — ATOMIC_SOURCE_EXTENSIONS gap (important): EXTENSION_MAP was missing .tsx/.jsx/.cjs/.scss/.less/.sass, so those extensions got application/octet-stream and were routed through individual POST instead of /_atomic despite being listed as atomic-source compatible. Now mapped to application/typescript/javascript and text/x-* respectively, so isTextFile() returns true and they take the atomic path. #2 — CLI --version out of sync: program.version() was hardcoded to '1.0.0' while package.json is at 1.0.1. Now reads from package.json via createRequire so boxel --version stays accurate on every release. #1 — --batch-size NaN guard: parseInt on bad input (e.g. "abc") returned NaN, which bypasses `?? 10` and flows into the uploader as NaN. Now uses a parsePositiveInt parser that throws InvalidArgumentError with a friendly message on non-positive-integer input. #5 — invalid-JSON test expectation: The test expected data.type='file' but the code (correctly) emits 'source' because /_atomic only accepts 'card' and 'source' resource types. Test updated to match the correct contract. All 164 tests pass (was 163/164 before this commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9dbfe46 commit 57385f5

4 files changed

Lines changed: 117 additions & 23 deletions

File tree

src/commands/push.ts

Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,32 @@ import * as fs from 'fs';
55
import * as path from 'path';
66
import * as crypto from 'crypto';
77

8+
interface SyncManifestFile {
9+
localHash: string; // md5 of file bytes at last sync
10+
remoteMtime: number; // remote mtime at last sync (seconds)
11+
}
12+
813
interface SyncManifest {
914
workspaceUrl: string;
10-
files: Record<string, string>; // relativePath -> contentHash
15+
lastSyncTime?: number;
16+
files: Record<string, SyncManifestFile>;
17+
}
18+
19+
// Old push-only manifest format kept for migration. Matches the detector in sync.ts.
20+
interface OldManifest {
21+
workspaceUrl: string;
22+
files: Record<string, string>;
23+
}
24+
25+
function isOldManifest(m: unknown): m is OldManifest {
26+
if (!m || typeof m !== 'object') return false;
27+
const files = (m as { files?: unknown }).files;
28+
if (!files || typeof files !== 'object') return false;
29+
for (const v of Object.values(files as Record<string, unknown>)) {
30+
if (typeof v === 'string') return true;
31+
if (v && typeof v === 'object') return false;
32+
}
33+
return false;
1134
}
1235

1336
function computeFileHash(filePath: string): string {
@@ -17,14 +40,24 @@ function computeFileHash(filePath: string): string {
1740

1841
function loadManifest(localDir: string): SyncManifest | null {
1942
const manifestPath = path.join(localDir, '.boxel-sync.json');
20-
if (fs.existsSync(manifestPath)) {
21-
try {
22-
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
23-
} catch {
24-
return null;
43+
if (!fs.existsSync(manifestPath)) return null;
44+
try {
45+
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
46+
if (isOldManifest(raw)) {
47+
// Migrate: old { files: {path: hash} } → new { files: {path: {localHash, remoteMtime: 0}} }
48+
const migrated: SyncManifest = {
49+
workspaceUrl: raw.workspaceUrl,
50+
files: {},
51+
};
52+
for (const [p, hash] of Object.entries(raw.files)) {
53+
migrated.files[p] = { localHash: hash, remoteMtime: 0 };
54+
}
55+
return migrated;
2556
}
57+
return raw as SyncManifest;
58+
} catch {
59+
return null;
2660
}
27-
return null;
2861
}
2962

3063
function saveManifest(localDir: string, manifest: SyncManifest): void {
@@ -105,14 +138,15 @@ class RealmPusher extends RealmSyncBase {
105138
continue;
106139
}
107140
const currentHash = computeFileHash(localPath);
108-
const previousHash = manifest.files[relativePath];
141+
const previousEntry = manifest.files[relativePath];
142+
const previousHash = previousEntry?.localHash;
109143

110144
if (previousHash !== currentHash) {
111145
filesToUpload.set(relativePath, localPath);
112146
} else {
113147
skipped++;
114-
// Keep the hash in new manifest
115-
newManifest.files[relativePath] = currentHash;
148+
// Keep the entry in new manifest (preserve remoteMtime)
149+
newManifest.files[relativePath] = previousEntry;
116150
}
117151
}
118152

@@ -147,7 +181,10 @@ class RealmPusher extends RealmSyncBase {
147181
for (const file of definitions) {
148182
try {
149183
await this.uploadFile(file.relativePath, file.localPath);
150-
newManifest.files[file.relativePath] = computeFileHash(file.localPath);
184+
newManifest.files[file.relativePath] = {
185+
localHash: computeFileHash(file.localPath),
186+
remoteMtime: 0, // will be filled in by the remote-mtime refresh below
187+
};
151188
} catch (error) {
152189
this.hasError = true;
153190
console.error(`Error uploading ${file.relativePath}:`, error);
@@ -165,12 +202,16 @@ class RealmPusher extends RealmSyncBase {
165202
dryRun: this.options.dryRun,
166203
});
167204

168-
// Update manifest for successfully uploaded files
169-
if (result.uploaded > 0) {
170-
for (const file of instances) {
171-
if (fs.existsSync(file.localPath)) {
172-
newManifest.files[file.relativePath] = computeFileHash(file.localPath);
173-
}
205+
// Mark only SUCCESSFUL files in the manifest. Failures stay out so the
206+
// next run retries them.
207+
const failedPaths = new Set(result.errors.map(e => e.path));
208+
for (const file of instances) {
209+
if (failedPaths.has(file.relativePath)) continue;
210+
if (fs.existsSync(file.localPath)) {
211+
newManifest.files[file.relativePath] = {
212+
localHash: computeFileHash(file.localPath),
213+
remoteMtime: 0, // refreshed below
214+
};
174215
}
175216
}
176217

@@ -188,7 +229,10 @@ class RealmPusher extends RealmSyncBase {
188229
try {
189230
await this.uploadFile(relativePath, localPath);
190231
// Add to manifest after successful upload
191-
newManifest.files[relativePath] = computeFileHash(localPath);
232+
newManifest.files[relativePath] = {
233+
localHash: computeFileHash(localPath),
234+
remoteMtime: 0, // refreshed below
235+
};
192236
} catch (error) {
193237
this.hasError = true;
194238
console.error(`Error uploading ${relativePath}:`, error);
@@ -228,6 +272,29 @@ class RealmPusher extends RealmSyncBase {
228272
}
229273
}
230274

275+
// Refresh remote mtimes for every file we just put in the manifest so
276+
// subsequent sync/pull operations don't mistakenly see remote-as-changed.
277+
// Skip on dry-run (no network side effects).
278+
if (!this.options.dryRun && Object.keys(newManifest.files).length > 0) {
279+
try {
280+
const remoteMtimes = await this.getRemoteMtimes();
281+
for (const [relPath, entry] of Object.entries(newManifest.files)) {
282+
const mtime = remoteMtimes.get(relPath);
283+
if (typeof mtime === 'number') {
284+
entry.remoteMtime = mtime;
285+
} else if (entry.remoteMtime === 0) {
286+
// Fall back to local time in seconds; imperfect but better than 0
287+
entry.remoteMtime = Math.floor(Date.now() / 1000);
288+
}
289+
}
290+
} catch (err) {
291+
// Non-fatal: manifest entries stay with remoteMtime:0 and sync will
292+
// reconcile on next run
293+
console.warn('Warning: could not refresh remote mtimes for manifest:', err);
294+
}
295+
newManifest.lastSyncTime = Date.now();
296+
}
297+
231298
// Save manifest for future incremental syncs
232299
if (!this.options.dryRun) {
233300
saveManifest(this.options.localDir, newManifest);

src/index.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
#!/usr/bin/env node
22

33
import 'dotenv/config';
4-
import { Command } from 'commander';
4+
import { Command, InvalidArgumentError } from 'commander';
5+
6+
/** Parse a positive integer from a CLI flag; throw a friendly error otherwise. */
7+
function parsePositiveInt(raw: string, _prev: unknown): number {
8+
const n = parseInt(raw, 10);
9+
if (!Number.isFinite(n) || n < 1) {
10+
throw new InvalidArgumentError(`expected a positive integer, got "${raw}"`);
11+
}
12+
return n;
13+
}
14+
515
import { pushCommand } from './commands/push.js';
616
import { pullCommand } from './commands/pull.js';
717
import { listCommand } from './commands/list.js';
@@ -26,13 +36,19 @@ import { repairRealmCommand, repairRealmsCommand } from './commands/repair.js';
2636
import { consolidateWorkspacesCommand } from './commands/consolidate.js';
2737
import { loadConfig } from './lib/realm-config.js';
2838
import { warnIfLegacyWorkspacePaths } from './lib/workspace-paths.js';
39+
import { createRequire } from 'module';
40+
41+
// Read version from package.json so `boxel --version` stays in sync with the
42+
// published package. Using require() avoids ESM JSON-import assertion syntax
43+
// that varies across Node versions.
44+
const pkg = createRequire(import.meta.url)('../package.json') as { version: string };
2945

3046
const program = new Command();
3147

3248
program
3349
.name('boxel')
3450
.description('CLI tools for syncing files between local directories and Boxel workspaces')
35-
.version('1.0.0');
51+
.version(pkg.version);
3652

3753
program.hook('preAction', (_thisCommand, actionCommand) => {
3854
const commandName = actionCommand.name();
@@ -66,7 +82,7 @@ program
6682
.option('--dry-run', 'Show what would be done without making changes')
6783
.option('--force', 'Upload all files, even if unchanged')
6884
.option('--batch', 'Use atomic batch upload for faster bulk operations (10 files per batch)')
69-
.option('--batch-size <n>', 'Files per batch when using --batch (default: 10)', parseInt)
85+
.option('--batch-size <n>', 'Files per batch when using --batch (default: 10)', parsePositiveInt)
7086
.action(async (localDir: string, workspaceUrl: string, options: { delete?: boolean; dryRun?: boolean; force?: boolean; batch?: boolean; batchSize?: number }) => {
7187
await pushCommand(localDir, workspaceUrl, options);
7288
});

src/lib/content-type.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ const EXTENSION_MAP: Record<string, string> = {
88
'.css': 'text/css',
99
'.js': 'application/javascript',
1010
'.ts': 'application/typescript',
11+
'.tsx': 'application/typescript',
12+
'.jsx': 'application/javascript',
1113
'.mjs': 'application/javascript',
14+
'.cjs': 'application/javascript',
15+
'.scss': 'text/x-scss',
16+
'.less': 'text/x-less',
17+
'.sass': 'text/x-sass',
1218
'.png': 'image/png',
1319
'.jpg': 'image/jpeg',
1420
'.jpeg': 'image/jpeg',
@@ -37,6 +43,8 @@ export function getContentType(filePath: string): string {
3743
}
3844

3945
export function isTextFile(contentType: string): boolean {
46+
// Includes the text/x-* family so .scss/.less/.sass route through the
47+
// text path and match ATOMIC_SOURCE_EXTENSIONS.
4048
return (
4149
contentType.startsWith('text/') ||
4250
contentType === 'application/json' ||

test/lib/batch-upload.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,15 +218,18 @@ describe('buildAtomicRequest', () => {
218218
expect(request['atomic:operations'][0].op).toBe('update');
219219
});
220220

221-
it('falls back to file type for invalid JSON', () => {
221+
it('falls back to source type for invalid JSON', () => {
222+
// The /_atomic endpoint only accepts 'card' and 'source' resource types.
223+
// When a .json file can't be parsed as a card, we fall back to 'source' so
224+
// the request still succeeds (rather than 'file', which isn't a valid type).
222225
const files = [
223226
createFile('bad.json', 'not valid json {{'),
224227
];
225228

226229
const request = buildAtomicRequest(files, 'https://realm.test/');
227230

228231
const op = request['atomic:operations'][0];
229-
expect(op.data.type).toBe('file');
232+
expect(op.data.type).toBe('source');
230233
expect(op.data.attributes?.content).toBe('not valid json {{');
231234
});
232235

0 commit comments

Comments
 (0)