Skip to content

Commit 5154a92

Browse files
feat(cli): sim files upload
The counterpart to `files download`, and hand-written for the same reason: POST /api/v2/files is multipart, which the generated flag surface cannot express, so `uploadFile` has been hidden since the start. Reads the file with openAsBlob so it stays on disk while the request is written, rather than buffering the whole upload in memory. Size is checked against the route's own 100MB ceiling before anything is sent. Content type comes from the extension, since the stored type decides whether the workspace later renders a file or offers it for download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU
1 parent eb9c1bb commit 5154a92

1 file changed

Lines changed: 102 additions & 1 deletion

File tree

packages/sim-cli/src/commands/hand-written.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { once } from 'node:events'
2-
import { createWriteStream, type WriteStream } from 'node:fs'
2+
import { createWriteStream, openAsBlob, type WriteStream } from 'node:fs'
3+
import { stat } from 'node:fs/promises'
34
import { basename } from 'node:path'
45
import chalk from 'chalk'
56
import type { Command } from 'commander'
@@ -116,7 +117,107 @@ function group(program: Command, name: string): Command {
116117
return created
117118
}
118119

120+
/**
121+
* The server stores whatever content type the part carries, falling back to
122+
* `application/octet-stream`, and that type is what later decides whether the
123+
* workspace renders a file or offers it as a download. Node does not ship a
124+
* mime table, so the common cases are listed and everything else falls back.
125+
*/
126+
const CONTENT_TYPES: Record<string, string> = {
127+
css: 'text/css',
128+
csv: 'text/csv',
129+
gif: 'image/gif',
130+
html: 'text/html',
131+
jpeg: 'image/jpeg',
132+
jpg: 'image/jpeg',
133+
js: 'text/javascript',
134+
json: 'application/json',
135+
md: 'text/markdown',
136+
pdf: 'application/pdf',
137+
png: 'image/png',
138+
svg: 'image/svg+xml',
139+
txt: 'text/plain',
140+
webp: 'image/webp',
141+
yaml: 'application/yaml',
142+
yml: 'application/yaml',
143+
zip: 'application/zip',
144+
}
145+
146+
function contentTypeFor(name: string): string {
147+
const dot = name.lastIndexOf('.')
148+
const extension = dot === -1 ? '' : name.slice(dot + 1).toLowerCase()
149+
return CONTENT_TYPES[extension] ?? 'application/octet-stream'
150+
}
151+
152+
/** The route's own ceiling. Checked here so a 100 MB body is never sent to be refused. */
153+
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
154+
119155
export function attachHandWritten(program: Command): void {
156+
// ── files upload ── multipart, which the generated flag surface cannot express ──
157+
group(program, 'files')
158+
.command('upload <path>')
159+
.description('Upload a file to the workspace')
160+
.option('--folder-id <id>', 'Target folder (defaults to the workspace root)')
161+
.option('--name <name>', 'Store it under a different name')
162+
.action(
163+
async (path: string, options: { folderId?: string; name?: string }, command: Command) => {
164+
const { client, profile } = clientFrom(command)
165+
const workspaceId = client.requireWorkspace()
166+
167+
if (!profile.apiKey) {
168+
throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0)
169+
}
170+
171+
let size: number
172+
try {
173+
const stats = await stat(path)
174+
if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0)
175+
size = stats.size
176+
} catch (error) {
177+
if (error instanceof SimApiError) throw error
178+
throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0)
179+
}
180+
181+
// Fail here rather than after streaming 100 MB the server will reject.
182+
if (size > MAX_UPLOAD_BYTES) {
183+
throw new SimApiError(
184+
`${path} is ${(size / 1024 / 1024).toFixed(1)}MB; the limit is 100MB`,
185+
0
186+
)
187+
}
188+
189+
const name = options.name ?? basename(path)
190+
const url = new URL(`${profile.endpoint}/api/v2/files`)
191+
url.searchParams.set('workspaceId', workspaceId)
192+
if (options.folderId) url.searchParams.set('folderId', options.folderId)
193+
194+
// `openAsBlob` keeps the file on disk and reads it as the request is
195+
// written; building a Buffer first would hold the whole upload in memory.
196+
const body = new FormData()
197+
body.append('file', await openAsBlob(path, { type: contentTypeFor(name) }), name)
198+
199+
const response = await fetch(url, {
200+
method: 'POST',
201+
headers: { 'x-api-key': profile.apiKey },
202+
body,
203+
})
204+
205+
const payload = (await response.json().catch(() => null)) as {
206+
data?: { id?: string }
207+
error?: { message?: string }
208+
} | null
209+
210+
if (!response.ok) {
211+
throw new SimApiError(
212+
payload?.error?.message ?? `Upload failed with status ${response.status}`,
213+
response.status
214+
)
215+
}
216+
217+
console.log(chalk.green(`✓ Uploaded ${name} (${payload?.data?.id ?? 'unknown id'})`))
218+
}
219+
)
220+
120221
// ── files download ── the response is binary, not the JSON envelope ────────
121222
group(program, 'files')
122223
.command('download <fileId>')

0 commit comments

Comments
 (0)