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
5 changes: 5 additions & 0 deletions .changeset/fix-externalize-binary-corruption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-cli": patch
---

Fix `squad externalize`/`internalize` corrupting non-UTF-8 files under `.squad/`. Both commands copied files through a UTF-8 string round-trip (`readSync`/`writeSync`), which replaced every byte >= 0x80 with U+FFFD and, because the source was deleted right after, destroyed the only intact copy. They now copy bytes via `storage.copySync`, so binary state (diagrams, archives, a future SQLite state db) survives the round trip intact.
20 changes: 7 additions & 13 deletions packages/squad-cli/src/cli/commands/externalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,9 @@ export function runExternalize(projectDir: string, projectKey?: string): void {
copyDirRecursive(src, dest);
storage.deleteDirSync?.(src);
} else {
const content = storage.readSync(src);
if (content != null) {
storage.mkdirSync(path.dirname(dest), { recursive: true });
storage.writeSync(dest, content);
}
// Copy bytes, not a UTF-8 string round-trip — readSync/writeSync
// corrupts any non-UTF-8 file, and the source is deleted right after.
storage.copySync(src, dest);
storage.deleteSync?.(src);
}
movedCount++;
Expand Down Expand Up @@ -158,10 +156,8 @@ export function runInternalize(projectDir: string): void {
if (storage.isDirectorySync(src)) {
copyDirRecursive(src, dest);
} else {
const content = storage.readSync(src);
if (content != null) {
storage.writeSync(dest, content);
}
// Byte-accurate copy (see externalize branch above).
storage.copySync(src, dest);
}
movedCount++;
}
Expand Down Expand Up @@ -206,10 +202,8 @@ function copyDirRecursive(src: string, dest: string): void {
if (storage.isDirectorySync(srcPath)) {
copyDirRecursive(srcPath, destPath);
} else {
const content = storage.readSync(srcPath);
if (content != null) {
storage.writeSync(destPath, content);
}
// Byte-accurate copy (see externalize branch above).
storage.copySync(srcPath, destPath);
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions test/external-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,30 @@ describe('runExternalize / runInternalize', () => {
expect(readFileSync(path.join(squadDir, 'log', 'session.md'), 'utf-8')).toBe('## Session 1\n');
});

// T4b: binary files survive externalize → internalize byte-for-byte (#1489)
it('preserves non-UTF-8 binary files across the round-trip', () => {
// Every byte value 0x00–0xFF: invalid as UTF-8, so a string round-trip
// would replace bytes >= 0x80 with U+FFFD and change the length.
const original = Buffer.alloc(256);
for (let i = 0; i < 256; i++) original[i] = i;
const assetsDir = path.join(squadDir, 'assets');
mkdirSync(assetsDir, { recursive: true });
writeFileSync(path.join(assetsDir, 'diagram.png'), original);

runExternalize(projectDir);

// The externalized copy must be identical bytes, not a lossy decode.
const key = deriveProjectKey(projectDir);
const externalDir = resolveExternalStateDir(key, false);
const externalized = readFileSync(path.join(externalDir, 'assets', 'diagram.png'));
expect(externalized.equals(original)).toBe(true);

runInternalize(projectDir);

const restored = readFileSync(path.join(squadDir, 'assets', 'diagram.png'));
expect(restored.equals(original)).toBe(true);
});

// T5: runInternalize cleans config — external-state fields removed
it('removes stateLocation/projectKey/teamRoot from config after internalize', () => {
// Seed config with an extra field so config.json survives (not deleted)
Expand Down