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..b3526ad --- /dev/null +++ b/packages/attache/.gitignore @@ -0,0 +1,37 @@ +# DATA! +projects/ + +# 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..ad6da1f --- /dev/null +++ b/packages/attache/README.md @@ -0,0 +1,10 @@ +# 💼 Attaché + +Attaché provides a JSON API and web UI for creating and viewing Projects. + +Each Project is essentially a folder of files that you can upload, download, and preview. + +## Quickstart + + bun install + bun dev diff --git a/packages/attache/package.json b/packages/attache/package.json new file mode 100644 index 0000000..2dd607b --- /dev/null +++ b/packages/attache/package.json @@ -0,0 +1,21 @@ +{ + "name": "attache", + "module": "src/server.tsx", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run --hot src/server.tsx", + "start": "bun run src/server.tsx" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hono": "^4.8.0", + "nanoid": "^5.1.5", + "@workshop/shared": "workspace:*" + } +} diff --git a/packages/attache/src/server.tsx b/packages/attache/src/server.tsx new file mode 100644 index 0000000..a107017 --- /dev/null +++ b/packages/attache/src/server.tsx @@ -0,0 +1,503 @@ +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' +import KV, { type Keys } from '@workshop/shared/kv' +import { Dirent } from 'node:fs' +import { html } from 'hono/html' + +declare global { + interface Window { + location: Location + } + var location: Location +} + +type Project = { + id: string + name: string + createdAt: string +} + +declare module "@workshop/shared/kv" { + interface Keys { + projects: Project[] + } +} + + +const app = new Hono() +const PROJECTS_DIR = './projects' + +app.get('/projects', async c => { + const projects = await loadProjects() + return c.html( + + + + ) +}) + +app.get('/project/:id', async c => { + const id = c.req.param('id') + const { project, files } = await loadProject(id, { files: true }) + if (!project) return c.json({ error: 'Not found' }, 404) + return c.html( + + + + ) +}) + +app.get('/new', async c => { + return c.html( + + + + ) +}) + +const api = new Hono() + +api.get('/projects', async c => { + const projects = await loadProjects() + return c.json(projects) +}) + +api.post('/projects', async c => { + const form = await c.req.formData() + const name = form.get('name') + if (!name) return c.json({ error: "Name is required" }, 400) + if (typeof name !== 'string') return c.json({ error: "Name must be a string" }, 400) + + 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]) +}) + +api.patch('/projects/:id', async c => { + const id = c.req.param('id') + const { name } = await c.req.json() + const { project } = await loadProject(id) + if (!project) return c.json({ error: 'Not found' }, 404) + project.name = name + await saveProject(project) + return c.json(project) +}) + +api.get('/project/:id', async c => { + const id = c.req.param('id') + try { + const { project, files } = await loadProject(id, { files: true }) + return c.json({ files, project }) + } catch { + return c.json({ error: 'Not found' }, 404) + } +}) + +api.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) + } +}) + +api.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', + }, + }) +}) + +api.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) + } +}) + +api.delete("/project/:id", async c => { + const id = c.req.param("id") + const { project } = await loadProject(id) + if (!project) return c.json({ error: "Not found" }, 404) + + const projects = await loadProjects() + const index = projects.findIndex(p => p.id === id) + if (index !== -1) { + projects.splice(index, 1) + await saveProjects(projects) + } + return c.json({ status: "ok" }) +}) + +app.route('/api', api) +app.use('/public/*', serveStatic({ root: './' })) + +async function loadProject(id: string, { files }: { files?: boolean } = {}): Promise<{ project: Project | null, files?: string[] }> { + const projects = await loadProjects() + const project = projects.find(p => p.id === id) + if (!project) return { project: null } + + if (files) { + const folder = join(PROJECTS_DIR, `project_${id}`) + const files = (await readdir(folder, { withFileTypes: true })).filter(f => f.isFile()).map(f => f.name) + return { project, files } + } + + return { project } +} + +async function loadProjects(): Promise { + return (await KV.get("projects")) ?? [] +} + +async function saveProject(project: Project) { + const projects = await loadProjects() + const index = projects.findIndex(p => p.id === project.id) + if (index !== -1) { + projects[index] = project + } else { + projects.push(project) + } + await saveProjects(projects) +} + +async function saveProjects(projects: Project[]) { + await KV.set("projects", projects) +} + +const Projects = ({ projects }: { projects: Project[] }) => { + return ( +
+

Projects

+ +
+ ) +} + +const Project = ({ project, files }: { project: Project, files: string[] }) => { + return ( +
+
+

{project.name}

+ +
+
+

Files

+ +
    + {files.map(file => ( +
  • + {file} +
    + + Download + + +
    +
  • + ))} +
+
+ {html` + + `} +
+ ) +} + +const NewProject = () => { + return ( +
+

New Project

+
+
+ + +
+
+ {html` + + `} +
+ ) +} + +const Layout = ({ children, title }: { children: any, title: string }) => { + return ( + + + {title} - 💼 Attaché + + + + + +
+ +
{children}
+
+ {html` + + `} + + + ) +} + +export default { + port: 3000, + fetch: app.fetch, + maxRequestBodySize: 1024 * 1024 * 1024, // 1GB +} \ No newline at end of file diff --git a/packages/attache/tsconfig.json b/packages/attache/tsconfig.json new file mode 100644 index 0000000..8da9238 --- /dev/null +++ b/packages/attache/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/packages/http/src/server.tsx b/packages/http/src/server.tsx index 5a673f9..b584bda 100644 --- a/packages/http/src/server.tsx +++ b/packages/http/src/server.tsx @@ -2,15 +2,27 @@ import { serve } from "bun" import { nanoRemix } from "@workshop/nano-remix" import { join } from "node:path" -const server = serve({ - routes: { - "/*": (req) => nanoRemix(req, { routesDir: join(import.meta.dir, "routes") }), - }, +type StartOptions = { + routesDir?: string +} - development: process.env.NODE_ENV !== "production" && { - hmr: true, - console: true, - }, -}) +function startServer(opts: StartOptions) { + const server = serve({ + routes: { + "/*": (req) => nanoRemix(req, opts), + }, -console.log(`🤖 Server running at ${server.url}`) + development: process.env.NODE_ENV !== "production" && { + hmr: true, + console: true, + }, + }) + + console.log(`🤖 Server running at ${server.url}`) +} + +if (import.meta.main) { + startServer({ routesDir: join(import.meta.dir, "routes") }) +} + +export { startServer } \ No newline at end of file diff --git a/packages/iago/README.md b/packages/iago/README.md index 3fb4cd5..b3bbdfd 100644 --- a/packages/iago/README.md +++ b/packages/iago/README.md @@ -1,15 +1,18 @@ -# iago +# 🐦 Iago -To install dependencies: +Iago is a bun server that takes pictures of index cards pinned to a corkboard and then, +using OpenAI, gives you back the text on the index cards. -```bash -bun install -``` +Requires: -To run: +- Mac Mini +- bun +- imagesnap (`brew install imagesnap`) +- corkboard w/ index cards +- cactus for moral support -```bash -bun run index.ts -``` -This project was created using `bun init` in bun v1.2.13. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. +Quickstart: + + bun install + bun dev \ No newline at end of file diff --git a/packages/shared/src/kv.ts b/packages/shared/src/kv.ts index 5daae36..07af1cc 100644 --- a/packages/shared/src/kv.ts +++ b/packages/shared/src/kv.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path" import type { Reminder } from "@/reminders" export type Conversation = { message: string; role: "user" | "assistant" } -type Keys = { +export interface Keys { threads: Record // threadId: previousResponseId reminders: Reminder[] todos: Record // todoId: todoText