|
1 | 1 | 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' |
3 | 4 | import { basename } from 'node:path' |
4 | 5 | import chalk from 'chalk' |
5 | 6 | import type { Command } from 'commander' |
@@ -116,7 +117,107 @@ function group(program: Command, name: string): Command { |
116 | 117 | return created |
117 | 118 | } |
118 | 119 |
|
| 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 | + |
119 | 155 | 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 | + |
120 | 221 | // ── files download ── the response is binary, not the JSON envelope ──────── |
121 | 222 | group(program, 'files') |
122 | 223 | .command('download <fileId>') |
|
0 commit comments