From c3f32120fc75bedfef15e26f245e9a626f933dcd Mon Sep 17 00:00:00 2001 From: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Thu, 19 Jun 2025 21:08:07 -0700 Subject: [PATCH] =?UTF-8?q?attach=C3=A9=20api=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/attache/.cursorrules | 43 +++++++++++ packages/attache/.gitignore | 34 +++++++++ packages/attache/README.md | 8 +++ packages/attache/package.json | 19 +++++ packages/attache/src/server.ts | 128 +++++++++++++++++++++++++++++++++ packages/attache/tsconfig.json | 28 ++++++++ 6 files changed, 260 insertions(+) create mode 100644 packages/attache/.cursorrules create mode 100644 packages/attache/.gitignore create mode 100644 packages/attache/README.md create mode 100644 packages/attache/package.json create mode 100644 packages/attache/src/server.ts create mode 100644 packages/attache/tsconfig.json diff --git a/packages/attache/.cursorrules b/packages/attache/.cursorrules new file mode 100644 index 0000000..35bb9fa --- /dev/null +++ b/packages/attache/.cursorrules @@ -0,0 +1,43 @@ +## Style + +- No semicolons — ever. +- No comments — ever. +- 2‑space indentation. +- Double quotes for strings. +- Trailing commas where ES5 allows (objects, arrays, imports). +- Keep lines <= 100 characters. +- End every file with a single newline. + +## TypeScript + +- This project runs on Bun. +- Assume `strict` mode is on (no implicit `any`). +- Prefer `const`; use `let` only when reassignment is required. +- Avoid the `any` type unless unavoidable. +- Use `import type { … }` when importing only types. +- In TypeScript files put npm imports first, then std imports, then library imports. + +## Tests + +Tests should use the toplevel `test()` and the `assert` library. + +Test files should always live in `test/` off the project root and be named `THING.test.ts` + +### Example Test + +``` +import { test } from "bun:test" +import assert from 'assert' + +test("1 + 2 = 3", () => { + assert.equal(1 + 2, 3) + assert.ok(true) +}) +``` + +## Assistant behaviour + +- Respond in a concise, direct tone. +- Do not ask follow‑up questions unless clarification is essential. + +Stay simple, readable, and stick to these rules. diff --git a/packages/attache/.gitignore b/packages/attache/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/attache/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/attache/README.md b/packages/attache/README.md new file mode 100644 index 0000000..25fa4c3 --- /dev/null +++ b/packages/attache/README.md @@ -0,0 +1,8 @@ +# attaché 💼 + +Stores files in folders, with style. + +## Quickstart + + bun install + bun dev diff --git a/packages/attache/package.json b/packages/attache/package.json new file mode 100644 index 0000000..0172c4f --- /dev/null +++ b/packages/attache/package.json @@ -0,0 +1,19 @@ +{ + "name": "attache", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run --watch src/server.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hono": "^4.8.0", + "nanoid": "^5.1.5" + } +} diff --git a/packages/attache/src/server.ts b/packages/attache/src/server.ts new file mode 100644 index 0000000..b41bf0c --- /dev/null +++ b/packages/attache/src/server.ts @@ -0,0 +1,128 @@ +import { Hono } from 'hono' +import { serveStatic } from 'hono/bun' +import { basename, join } from 'path' +import { mkdir, readdir } from "node:fs/promises" +import { nanoid } from 'nanoid' + +const app = new Hono() +const PROJECTS_DIR = './projects' +const PROJECTS_FILE = join(PROJECTS_DIR, 'projects.json') + +type Project = { + id: string + name: string + createdAt: string +} + +app.get('/projects', async c => { + const projects = await loadProjects() + return c.json(projects) +}) + +app.post('/projects', async c => { + const { name } = await c.req.json() + const projects = await loadProjects() + + if (projects.some(p => p.name === name)) + return c.json({ error: "A project with this name already exists" }, 409) + + const id = nanoid(6) + projects.push({ id, name, createdAt: new Date().toISOString() }) + await mkdir(join(PROJECTS_DIR, `project_${id}`), { recursive: true }) + await saveProjects(projects) + return c.json(projects[projects.length - 1]) +}) + +app.patch('/projects/:id', async c => { + const id = c.req.param('id') + const { name } = await c.req.json() + const projects = await loadProjects() + const proj = projects.find(p => p.id === id) + if (!proj) return c.json({ error: 'Not found' }, 404) + proj.name = name + await saveProjects(projects) + return c.json(proj) +}) + +app.get('/project/:id', async c => { + const id = c.req.param('id') + const folder = join(PROJECTS_DIR, `project_${id}`) + try { + return c.json(await readdir(folder, { withFileTypes: true })) + } catch { + return c.json({ error: 'Not found' }, 404) + } +}) + +app.post('/project/:id/upload', async c => { + const id = c.req.param('id') + + if (id !== basename(id)) + return c.json({ error: 'Invalid project id' }, 400) + + const folder = join(PROJECTS_DIR, `project_${id}`) + try { + await mkdir(folder, { recursive: true }) + + const form = await c.req.formData() + const file = form.get('file') + if (!(file instanceof File)) + return c.json({ error: 'Invalid file' }, 400) + + + // strip any directory parts from the uploaded filename + const filename = basename(file.name) + const filepath = join(folder, filename) + await Bun.write(filepath, await file.arrayBuffer()) + + return c.json({ status: 'ok' }) + } catch (err) { + console.error('Upload error:', err) + return c.json({ error: 'Upload failed' }, 500) + } +}) + +app.get('/project/:id/file/:filename', async c => { + const { id, filename } = c.req.param() + const path = join(PROJECTS_DIR, `project_${id}`, filename) + const file = Bun.file(path) + if (!(await file.exists())) return c.json({ error: 'Not found' }, 404) + + return new Response(file, { + headers: { + 'Content-Type': file.type || 'application/octet-stream', + }, + }) +}) + +app.delete('/project/:id/file/:filename', async c => { + const { id, filename } = c.req.param() + const path = join(PROJECTS_DIR, `project_${id}`, filename) + try { + await Bun.file(path).delete() + return c.text('Deleted') + } catch { + return c.json({ error: 'Not found' }, 404) + } +}) + +app.use('/public/*', serveStatic({ root: './' })) + +async function loadProjects(): Promise { + try { + const file = Bun.file(PROJECTS_FILE) + const text = await file.text() + return JSON.parse(text) + } catch { + return [] + } +} + +async function saveProjects(projects: Project[]) { + await Bun.write(PROJECTS_FILE, JSON.stringify(projects, null, 2)) +} + +export default { + port: 3000, + fetch: app.fetch +} \ No newline at end of file diff --git a/packages/attache/tsconfig.json b/packages/attache/tsconfig.json new file mode 100644 index 0000000..9c62f74 --- /dev/null +++ b/packages/attache/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}