attaché api draft

This commit is contained in:
Chris Wanstrath 2025-06-19 21:08:07 -07:00
parent 0f3bc47164
commit c3f32120fc
6 changed files with 260 additions and 0 deletions

View File

@ -0,0 +1,43 @@
## Style
- No semicolons — ever.
- No comments — ever.
- 2space 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 followup questions unless clarification is essential.
Stay simple, readable, and stick to these rules.

34
packages/attache/.gitignore vendored Normal file
View File

@ -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

View File

@ -0,0 +1,8 @@
# attaché 💼
Stores files in folders, with style.
## Quickstart
bun install
bun dev

View File

@ -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"
}
}

View File

@ -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<Project[]> {
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
}

View File

@ -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
}
}