diff --git a/package-lock.json b/package-lock.json index 7fa9def..253fa91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@insforge/cli", - "version": "0.1.90", + "version": "0.1.91", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/cli", - "version": "0.1.90", + "version": "0.1.91", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.9.1", diff --git a/package.json b/package.json index 6e2c85b..2568a7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/cli", - "version": "0.1.90", + "version": "0.1.91", "description": "InsForge CLI - Command line tool for InsForge platform", "type": "module", "bin": { diff --git a/src/commands/storage/upload.test.ts b/src/commands/storage/upload.test.ts new file mode 100644 index 0000000..9a546b0 --- /dev/null +++ b/src/commands/storage/upload.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { resolveUploadContentType } from './upload.js'; + +describe('resolveUploadContentType', () => { + it('uses an explicit --content-type over inference', () => { + expect(resolveUploadContentType('photo.png', 'image/webp')).toBe('image/webp'); + }); + + it('infers from the file extension when no flag is given', () => { + expect(resolveUploadContentType('photo.png')).toBe('image/png'); + expect(resolveUploadContentType('/tmp/report.pdf')).toBe('application/pdf'); + }); + + it('treats an empty or whitespace flag as not provided', () => { + expect(resolveUploadContentType('photo.png', '')).toBe('image/png'); + expect(resolveUploadContentType('photo.png', ' ')).toBe('image/png'); + }); + + it('falls back to octet-stream for an unknown extension', () => { + expect(resolveUploadContentType('archive.unknownext')).toBe('application/octet-stream'); + expect(resolveUploadContentType('noext')).toBe('application/octet-stream'); + }); +}); diff --git a/src/commands/storage/upload.ts b/src/commands/storage/upload.ts index 66ed104..3849b83 100644 --- a/src/commands/storage/upload.ts +++ b/src/commands/storage/upload.ts @@ -5,6 +5,16 @@ import { getProjectConfig } from '../../lib/config.js'; import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts, CLIError, ProjectNotLinkedError } from '../../lib/errors.js'; import { outputJson, outputSuccess } from '../../lib/output.js'; +import { mimeTypeFromName } from '../../lib/mime.js'; + +/** + * Resolve the content type for an upload: an explicit (non-empty) `--content-type` + * wins, otherwise infer from the file extension, otherwise fall back to the + * storage default. Exported for testing. + */ +export function resolveUploadContentType(filePath: string, explicit?: string): string { + return explicit?.trim() || mimeTypeFromName(filePath) || 'application/octet-stream'; +} export function registerStorageUploadCommand(storageCmd: Command): void { storageCmd @@ -12,6 +22,7 @@ export function registerStorageUploadCommand(storageCmd: Command): void { .description('Upload a file to a storage bucket') .requiredOption('--bucket ', 'Target bucket name') .option('--key ', 'Object key (defaults to filename)') + .option('--content-type ', 'MIME type to store (defaults to one inferred from the file extension)') .action(async (file: string, opts, cmd) => { const { json } = getRootOpts(cmd); try { @@ -28,9 +39,15 @@ export function registerStorageUploadCommand(storageCmd: Command): void { const objectKey = opts.key ?? basename(file); const bucketName = opts.bucket; - // Build multipart form data + // Resolve the content type: explicit flag wins, then infer from the + // file's extension. Without this a typeless Blob is stored as + // application/octet-stream regardless of the actual file type. + const contentType = resolveUploadContentType(file, opts.contentType); + + // Build multipart form data. The Blob's type becomes the multipart + // part's Content-Type, which the backend stores as the object's MIME type. const formData = new FormData(); - const blob = new Blob([fileContent]); + const blob = new Blob([fileContent], { type: contentType }); formData.append('file', blob, objectKey); // PUT /api/storage/buckets/{bucket}/objects/{key} for named upload diff --git a/src/lib/mime.test.ts b/src/lib/mime.test.ts new file mode 100644 index 0000000..c688f60 --- /dev/null +++ b/src/lib/mime.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { mimeTypeFromName } from './mime.js'; + +describe('mimeTypeFromName', () => { + it('infers common types from the extension', () => { + expect(mimeTypeFromName('photo.png')).toBe('image/png'); + expect(mimeTypeFromName('a.jpeg')).toBe('image/jpeg'); + expect(mimeTypeFromName('report.pdf')).toBe('application/pdf'); + expect(mimeTypeFromName('data.json')).toBe('application/json'); + expect(mimeTypeFromName('clip.mp4')).toBe('video/mp4'); + }); + + it('is case-insensitive and handles full paths', () => { + expect(mimeTypeFromName('IMG.PNG')).toBe('image/png'); + expect(mimeTypeFromName('/tmp/sub dir/cover.JPG')).toBe('image/jpeg'); + expect(mimeTypeFromName('archive.tar.gz')).toBe('application/gzip'); + }); + + it('ignores dots in parent directories (basename only)', () => { + expect(mimeTypeFromName('/home/user.name/photo.png')).toBe('image/png'); + expect(mimeTypeFromName('/home/user.name/datafile')).toBeUndefined(); + expect(mimeTypeFromName('C:\\Users\\a.b\\report.pdf')).toBe('application/pdf'); + }); + + it('returns undefined for unknown or missing extensions', () => { + expect(mimeTypeFromName('file.unknownext')).toBeUndefined(); + expect(mimeTypeFromName('noext')).toBeUndefined(); + expect(mimeTypeFromName('trailingdot.')).toBeUndefined(); + expect(mimeTypeFromName('.gitignore')).toBeUndefined(); + }); +}); diff --git a/src/lib/mime.ts b/src/lib/mime.ts new file mode 100644 index 0000000..ae90479 --- /dev/null +++ b/src/lib/mime.ts @@ -0,0 +1,85 @@ +/** + * Minimal extension → MIME type lookup for storage uploads. + * + * The CLI reads raw bytes off disk, so unlike a browser `File` there is no + * `type` to read — without inference every upload would be stored as + * `application/octet-stream`. We infer from the filename extension (the same + * approach `aws s3 cp` uses). Unknown extensions fall back to octet-stream, + * which is the server's default anyway, so this is strictly an improvement. + */ + +const EXTENSION_MIME_TYPES: Record = { + // Images + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', + ico: 'image/x-icon', + avif: 'image/avif', + heic: 'image/heic', + heif: 'image/heif', + tif: 'image/tiff', + tiff: 'image/tiff', + // Documents + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + // Text / data + txt: 'text/plain', + csv: 'text/csv', + md: 'text/markdown', + html: 'text/html', + htm: 'text/html', + css: 'text/css', + js: 'text/javascript', + mjs: 'text/javascript', + json: 'application/json', + xml: 'application/xml', + yaml: 'application/yaml', + yml: 'application/yaml', + // Archives + zip: 'application/zip', + gz: 'application/gzip', + tar: 'application/x-tar', + rar: 'application/vnd.rar', + '7z': 'application/x-7z-compressed', + // Audio + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + m4a: 'audio/mp4', + flac: 'audio/flac', + aac: 'audio/aac', + // Video + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + avi: 'video/x-msvideo', + mkv: 'video/x-matroska', + // Fonts + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + otf: 'font/otf', +}; + +/** + * Infer a MIME type from a filename/path extension. Returns `undefined` when the + * extension is missing or unrecognized. + */ +export function mimeTypeFromName(name: string): string | undefined { + // Strip any directory portion first so a dot in a parent directory + // (e.g. "/home/user.name/datafile") can't be mistaken for an extension. + const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1); + const lastDot = base.lastIndexOf('.'); + if (lastDot < 0 || lastDot === base.length - 1) return undefined; + const ext = base.slice(lastDot + 1).toLowerCase(); + return EXTENSION_MIME_TYPES[ext]; +}