77} from '../../lib/profile-manager' ;
88import { ensureTrailingSlash } from '@cardstack/runtime-common/paths' ;
99import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type' ;
10+ import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type' ;
1011import { FG_GREEN , FG_RED , DIM , RESET } from '../../lib/colors' ;
1112import { cliLog } from '../../lib/cli-log' ;
1213
@@ -26,15 +27,19 @@ interface WriteCliOptions {
2627}
2728
2829/**
29- * Write a file to a realm. Content is sent as-is with card+source MIME type.
30- * Path should include the file extension.
30+ * Write a file to a realm. Path should include the file extension.
31+ *
32+ * String content is sent with the card+source MIME type (the text path
33+ * .gts / .json / .md / etc. always took). Binary content (a `Uint8Array`,
34+ * including the `Buffer` subclass) is sent with `application/octet-stream`,
35+ * which the realm-server routes to `upsertBinaryFile` and writes verbatim.
3136 *
3237 * Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
3338 */
3439export async function write (
3540 realmUrl : string ,
3641 path : string ,
37- content : string ,
42+ content : string | Uint8Array ,
3843 options ?: WriteCommandOptions ,
3944) : Promise < WriteResult > {
4045 let pm = options ?. profileManager ?? getProfileManager ( ) ;
@@ -47,15 +52,38 @@ export async function write(
4752 }
4853
4954 let url = new URL ( path , ensureTrailingSlash ( realmUrl ) ) . href ;
55+ let isBinary = typeof content !== 'string' ;
56+
57+ // Defense-in-depth for programmatic callers (BoxelClient.write, tests).
58+ // The CLI wrapper has an earlier guard against `--file image.png` →
59+ // `notes.md` style misuse, but the library function is also reachable
60+ // without going through that branch. Reject the mismatch here so raw
61+ // bytes never land at a text extension (corrupt-on-read) and a UTF-8
62+ // string never lands at a binary extension (corrupt-on-write).
63+ let pathIsBinary = isBinaryFilename ( path ) ;
64+ if ( pathIsBinary !== isBinary ) {
65+ return {
66+ ok : false ,
67+ error :
68+ `Path ${ path } is ${ pathIsBinary ? 'binary' : 'text' } by extension ` +
69+ `but content is ${ isBinary ? 'bytes' : 'a string' } . ` +
70+ `Refusing to write to avoid silent corruption.` ,
71+ } ;
72+ }
5073
5174 try {
5275 let response = await pm . authedRealmFetch ( url , {
5376 method : 'POST' ,
54- headers : {
55- Accept : SupportedMimeType . CardSource ,
56- 'Content-Type' : SupportedMimeType . CardSource ,
57- } ,
58- body : content ,
77+ headers : isBinary
78+ ? { 'Content-Type' : SupportedMimeType . OctetStream }
79+ : {
80+ Accept : SupportedMimeType . CardSource ,
81+ 'Content-Type' : SupportedMimeType . CardSource ,
82+ } ,
83+ // Both branches of `content: string | Uint8Array` are valid
84+ // BodyInit values, but TS narrows them as a union that doesn't
85+ // unify against the fetch signature without a hint.
86+ body : content as BodyInit ,
5987 } ) ;
6088
6189 if ( ! response . ok ) {
@@ -103,10 +131,30 @@ export function registerWriteCommand(parent: Command): void {
103131 )
104132 . option ( '--json' , 'Output raw JSON response' )
105133 . action ( async ( filePath : string , opts : WriteCliOptions ) => {
106- let content : string ;
134+ let content : string | Uint8Array ;
107135 if ( opts . file ) {
136+ // Refuse a source/destination binary-classification mismatch
137+ // (e.g., `write notes.md --file image.png`) — otherwise raw
138+ // bytes would land at a text extension and corrupt-on-read.
139+ const srcIsBinary = isBinaryFilename ( opts . file ) ;
140+ const dstIsBinary = isBinaryFilename ( filePath ) ;
141+ if ( srcIsBinary !== dstIsBinary ) {
142+ stderr (
143+ `${ FG_RED } Error:${ RESET } source file ${ opts . file } is ${
144+ srcIsBinary ? 'binary' : 'text'
145+ } but destination path ${ filePath } is ${
146+ dstIsBinary ? 'binary' : 'text'
147+ } . Refusing to write to avoid silent corruption — rename the destination to match.`,
148+ ) ;
149+ process . exit ( 1 ) ;
150+ }
108151 try {
109- content = readFileSync ( opts . file , 'utf-8' ) ;
152+ // Binary source files are read as raw bytes so write() can
153+ // hand them to the realm unchanged; forcing utf-8 would
154+ // corrupt PNG / PDF / font / etc. payloads silently.
155+ content = srcIsBinary
156+ ? readFileSync ( opts . file )
157+ : readFileSync ( opts . file , 'utf-8' ) ;
110158 } catch ( err ) {
111159 stderr (
112160 `${ FG_RED } Error:${ RESET } Could not read file: ${ err instanceof Error ? err . message : String ( err ) } ` ,
0 commit comments