diff --git a/.gitignore b/.gitignore index 2cb95a7f..1f697d39 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ count.txt todos.json .cta.json .vscode +.env*.local diff --git a/src/lib/uploadFiles.test.ts b/src/lib/uploadFiles.test.ts new file mode 100644 index 00000000..d562b882 --- /dev/null +++ b/src/lib/uploadFiles.test.ts @@ -0,0 +1,91 @@ +/* @vitest-environment node */ +import { gzipSync, strToU8, zipSync } from 'fflate' +import { describe, expect, it } from 'vitest' +import { expandFiles } from './uploadFiles' + +if (typeof File === 'undefined') { + class NodeFile extends Blob { + name: string + lastModified: number + + constructor(parts: BlobPart[], name: string, options?: FilePropertyBag) { + super(parts, options) + this.name = name + this.lastModified = options?.lastModified ?? Date.now() + } + } + // @ts-expect-error Node test environment polyfill + globalThis.File = NodeFile +} + +function buildTar(entries: Array<{ name: string; content: string }>) { + const blocks: Uint8Array[] = [] + for (const entry of entries) { + const content = strToU8(entry.content) + const header = new Uint8Array(512) + writeString(header, entry.name, 0, 100) + writeString(header, '0000777', 100, 8) + writeString(header, '0000000', 108, 8) + writeString(header, '0000000', 116, 8) + writeString(header, content.length.toString(8).padStart(11, '0'), 124, 12) + writeString(header, '00000000000', 136, 12) + header[156] = '0'.charCodeAt(0) + writeString(header, 'ustar', 257, 6) + for (let i = 148; i < 156; i += 1) { + header[i] = 32 + } + let sum = 0 + for (const byte of header) sum += byte + writeString(header, sum.toString(8).padStart(6, '0'), 148, 6) + header[154] = 0 + header[155] = 32 + blocks.push(header) + blocks.push(content) + const pad = (512 - (content.length % 512)) % 512 + if (pad) blocks.push(new Uint8Array(pad)) + } + blocks.push(new Uint8Array(1024)) + const total = blocks.reduce((sum, block) => sum + block.length, 0) + const buffer = new Uint8Array(total) + let offset = 0 + for (const block of blocks) { + buffer.set(block, offset) + offset += block.length + } + return buffer +} + +function writeString(target: Uint8Array, value: string, start: number, length: number) { + const bytes = strToU8(value) + target.set(bytes.subarray(0, length), start) +} + +describe('expandFiles', () => { + it('expands zip archives into files', async () => { + const zip = zipSync({ + 'SKILL.md': strToU8('hello'), + 'docs/readme.txt': strToU8('doc'), + }) + const zipFile = new File([zip], 'pack.zip', { type: 'application/zip' }) + const result = await expandFiles([zipFile]) + expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt']) + }) + + it('expands gzipped tar archives into files', async () => { + const tar = buildTar([ + { name: 'SKILL.md', content: 'hi' }, + { name: 'notes.txt', content: 'yo' }, + ]) + const tgz = gzipSync(tar) + const tgzFile = new File([tgz], 'bundle.tgz', { type: 'application/gzip' }) + const result = await expandFiles([tgzFile]) + expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt']) + }) + + it('expands .gz single files', async () => { + const gz = gzipSync(strToU8('content')) + const gzFile = new File([gz], 'skill.md.gz', { type: 'application/gzip' }) + const result = await expandFiles([gzFile]) + expect(result.map((file) => file.name)).toEqual(['skill.md']) + }) +}) diff --git a/src/lib/uploadFiles.ts b/src/lib/uploadFiles.ts new file mode 100644 index 00000000..2d17e521 --- /dev/null +++ b/src/lib/uploadFiles.ts @@ -0,0 +1,102 @@ +import { gunzipSync, unzipSync } from 'fflate' + +const TEXT_TYPES = new Map([ + ['md', 'text/markdown'], + ['markdown', 'text/markdown'], + ['txt', 'text/plain'], + ['json', 'application/json'], + ['yaml', 'text/yaml'], + ['yml', 'text/yaml'], + ['toml', 'text/plain'], + ['js', 'text/javascript'], + ['ts', 'text/plain'], + ['tsx', 'text/plain'], + ['jsx', 'text/plain'], + ['css', 'text/css'], + ['html', 'text/html'], + ['svg', 'image/svg+xml'], +]) + +export async function expandFiles(selected: File[]) { + const expanded: File[] = [] + for (const file of selected) { + const lower = file.name.toLowerCase() + if (lower.endsWith('.zip')) { + const entries = unzipSync(new Uint8Array(await readArrayBuffer(file))) + for (const [path, data] of Object.entries(entries)) { + if (!path || path.endsWith('/')) continue + expanded.push( + new File([data], normalizePath(path), { + type: guessContentType(path), + }), + ) + } + continue + } + if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) { + const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file))) + for (const entry of untar(unpacked)) { + expanded.push( + new File([entry.data], normalizePath(entry.path), { + type: guessContentType(entry.path), + }), + ) + } + continue + } + if (lower.endsWith('.gz')) { + const unpacked = gunzipSync(new Uint8Array(await readArrayBuffer(file))) + const name = file.name.replace(/\.gz$/i, '') + expanded.push(new File([unpacked], name, { type: guessContentType(name) })) + continue + } + expanded.push(file) + } + return expanded +} + +async function readArrayBuffer(file: Blob) { + if (typeof file.arrayBuffer === 'function') { + return file.arrayBuffer() + } + return new Response(file).arrayBuffer() +} + +function guessContentType(path: string) { + const ext = path.split('.').pop()?.toLowerCase() + if (!ext) return 'text/plain' + return TEXT_TYPES.get(ext) ?? 'text/plain' +} + +function normalizePath(path: string) { + return path.replace(/^\.\/+/, '').replace(/^\/+/, '') +} + +function untar(bytes: Uint8Array) { + const entries: Array<{ path: string; data: Uint8Array }> = [] + let offset = 0 + while (offset + 512 <= bytes.length) { + const header = bytes.subarray(offset, offset + 512) + if (header.every((byte) => byte === 0)) break + const name = readString(header.subarray(0, 100)) + const size = readOctal(header.subarray(124, 136)) + const typeflag = header[156] + offset += 512 + const data = bytes.subarray(offset, offset + size) + offset += Math.ceil(size / 512) * 512 + if (!name || typeflag === 53) continue + entries.push({ path: name, data }) + } + return entries +} + +function readString(bytes: Uint8Array) { + const end = bytes.indexOf(0) + const slice = end === -1 ? bytes : bytes.subarray(0, end) + return new TextDecoder().decode(slice).trim() +} + +function readOctal(bytes: Uint8Array) { + const raw = readString(bytes) + return raw ? Number.parseInt(raw, 8) : 0 +} diff --git a/src/routes/upload.test.tsx b/src/routes/upload.test.tsx new file mode 100644 index 00000000..09ac6711 --- /dev/null +++ b/src/routes/upload.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import { Upload } from './upload' + +const generateUploadUrl = vi.fn() +const publishVersion = vi.fn() + +vi.mock('convex/react', () => ({ + useConvexAuth: () => ({ isAuthenticated: true }), + useMutation: () => generateUploadUrl, + useAction: () => publishVersion, +})) + +describe('Upload route', () => { + beforeEach(() => { + generateUploadUrl.mockReset() + publishVersion.mockReset() + }) + + it('shows validation issues and disables publish by default', () => { + render() + const publishButton = screen.getByRole('button', { name: /publish/i }) + expect(publishButton).toBeTruthy() + expect((publishButton as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByText(/Slug is required/i)).toBeTruthy() + expect(screen.getByText(/Display name is required/i)).toBeTruthy() + expect(screen.getByText(/Changelog is required/i)).toBeTruthy() + }) + + it('marks the input for folder uploads', async () => { + render() + const input = screen.getByTestId('upload-input') + await waitFor(() => { + expect(input.getAttribute('webkitdirectory')).not.toBeNull() + }) + }) + + it('enables publish when fields and files are valid, and allows removing files', async () => { + render() + fireEvent.change(screen.getByPlaceholderText('my-skill-pack'), { + target: { value: 'cool-skill' }, + }) + fireEvent.change(screen.getByPlaceholderText('My Skill Pack'), { + target: { value: 'Cool Skill' }, + }) + fireEvent.change(screen.getByPlaceholderText('1.0.0'), { + target: { value: '1.2.3' }, + }) + fireEvent.change(screen.getByPlaceholderText('latest, beta'), { + target: { value: 'latest' }, + }) + fireEvent.change(screen.getByPlaceholderText('What changed in this version?'), { + target: { value: 'Initial drop.' }, + }) + + const file = new File(['hello'], 'SKILL.md', { type: 'text/markdown' }) + const input = screen.getByTestId('upload-input') as HTMLInputElement + fireEvent.change(input, { target: { files: [file] } }) + + const publishButton = screen.getByRole('button', { name: /publish/i }) as HTMLButtonElement + await waitFor(() => { + expect(publishButton.disabled).toBe(false) + }) + expect(screen.getByText(/Ready to publish/i)).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /remove/i })) + await waitFor(() => { + expect(publishButton.disabled).toBe(true) + }) + expect(screen.getByText(/Add at least one file/i)).toBeTruthy() + }) +}) diff --git a/src/routes/upload.tsx b/src/routes/upload.tsx index 5109ca65..2ecd072d 100644 --- a/src/routes/upload.tsx +++ b/src/routes/upload.tsx @@ -1,13 +1,17 @@ import { createFileRoute } from '@tanstack/react-router' import { useAction, useConvexAuth, useMutation } from 'convex/react' -import { useState } from 'react' +import semver from 'semver' +import { useEffect, useMemo, useRef, useState } from 'react' import { api } from '../../convex/_generated/api' +import { expandFiles } from '../lib/uploadFiles' + +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ export const Route = createFileRoute('/upload')({ component: Upload, }) -function Upload() { +export function Upload() { const { isAuthenticated } = useConvexAuth() const generateUploadUrl = useMutation(api.uploads.generateUploadUrl) const publishVersion = useAction(api.skills.publishVersion) @@ -19,6 +23,78 @@ function Upload() { const [changelog, setChangelog] = useState('') const [status, setStatus] = useState(null) const [error, setError] = useState(null) + const [isDragging, setIsDragging] = useState(false) + const fileInputRef = useRef(null) + const maxBytes = 50 * 1024 * 1024 + const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]) + const hasSkillFile = useMemo( + () => + files.some( + (file) => file.name.toLowerCase() === 'skill.md' || file.name.toLowerCase() === 'skills.md', + ), + [files], + ) + const sizeLabel = totalBytes ? formatBytes(totalBytes) : '0 B' + const trimmedSlug = slug.trim() + const trimmedName = displayName.trim() + const trimmedChangelog = changelog.trim() + const parsedTags = useMemo( + () => + tags + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean), + [tags], + ) + const validation = useMemo(() => { + const issues: string[] = [] + if (!trimmedSlug) { + issues.push('Slug is required.') + } else if (!SLUG_PATTERN.test(trimmedSlug)) { + issues.push('Slug must be lowercase and use dashes only.') + } + if (!trimmedName) { + issues.push('Display name is required.') + } + if (!semver.valid(version)) { + issues.push('Version must be valid semver (e.g. 1.0.0).') + } + if (parsedTags.length === 0) { + issues.push('At least one tag is required.') + } + if (!trimmedChangelog) { + issues.push('Changelog is required.') + } + if (files.length === 0) { + issues.push('Add at least one file.') + } + if (!hasSkillFile) { + issues.push('SKILL.md is required.') + } + if (totalBytes > maxBytes) { + issues.push('Total file size exceeds 50MB.') + } + return { + issues, + ready: issues.length === 0, + } + }, [ + trimmedSlug, + trimmedName, + version, + parsedTags.length, + trimmedChangelog, + files.length, + hasSkillFile, + totalBytes, + maxBytes, + ]) + + useEffect(() => { + if (!fileInputRef.current) return + fileInputRef.current.setAttribute('webkitdirectory', '') + fileInputRef.current.setAttribute('directory', '') + }, []) if (!isAuthenticated) { return ( @@ -30,18 +106,13 @@ function Upload() { async function handleSubmit(event: React.FormEvent) { event.preventDefault() - if (files.length === 0) return + if (!validation.ready) return setError(null) - const totalBytes = files.reduce((sum, file) => sum + file.size, 0) - if (totalBytes > 50 * 1024 * 1024) { + if (totalBytes > maxBytes) { setError('Total size exceeds 50MB per version.') return } - if ( - !files.some( - (file) => file.name.toLowerCase() === 'skill.md' || file.name.toLowerCase() === 'skills.md', - ) - ) { + if (!hasSkillFile) { setError('SKILL.md is required.') return } @@ -84,74 +155,207 @@ function Upload() { setStatus('Published.') } + async function handleFilesSelected(selected: File[]) { + if (selected.length === 0) return + setError(null) + setStatus('Preparing files…') + const expanded = await expandFiles(selected) + setStatus(null) + const next = new Map() + for (const file of files) { + const key = `${file.webkitRelativePath || file.name}:${file.size}` + next.set(key, file) + } + for (const file of expanded) { + const key = `${file.webkitRelativePath || file.name}:${file.size}` + next.set(key, file) + } + setFiles(Array.from(next.values())) + } + + function handleRemoveFile(target: File) { + setFiles((current) => + current.filter( + (file) => + `${file.webkitRelativePath || file.name}:${file.size}` !== + `${target.webkitRelativePath || target.name}:${target.size}`, + ), + ) + } + + function handleDrop(event: React.DragEvent) { + event.preventDefault() + setIsDragging(false) + handleFilesSelected(Array.from(event.dataTransfer.files ?? [])) + } + + function handleDragOver(event: React.DragEvent) { + event.preventDefault() + setIsDragging(true) + } + + function handleDragLeave() { + setIsDragging(false) + } + return ( -
-

Publish a skill

-

Bundle SKILL.md + text files, then ship.

-
- - - - -