memory: log task worker session - due dates, subtasks, task page

This commit is contained in:
2026-01-29 07:07:45 +00:00
parent ad145c9ec3
commit 4663a45b45
39 changed files with 1044 additions and 3 deletions

132
skills/app-builder/SKILL.md Normal file
View File

@@ -0,0 +1,132 @@
---
name: app-builder
description: Build and deploy web applications following a standardized process. Use when asked to create a new app, prototype, SaaS tool, or web project. Covers the full lifecycle from ideation through production deployment with a repeatable stack (React + Vite + Tailwind, Elysia + Bun, PostgreSQL + Drizzle, BetterAuth, Dokploy). Use for any request like "build me an app", "create a tool", "make a dashboard", or "prototype this idea".
---
# App Builder
Standardized process for building and deploying web applications. Every app follows the same stack, structure, and lifecycle.
## Stack
| Layer | Tech | Why |
|-------|------|-----|
| Frontend | React + Vite + TypeScript + Tailwind | Fast, typed, utility CSS |
| Backend | Elysia + Bun + TypeScript | Fast, type-safe, great DX |
| Database | PostgreSQL + Drizzle ORM | Industry standard + lightweight ORM |
| Auth | BetterAuth (invite-only signup) | TypeScript-native, modern |
| AI (if needed) | LangChain.js | Model-agnostic, swap providers |
| Email (if needed) | Resend | Simple API, 3k free/month |
| Jobs (if needed) | pg-boss | Postgres-backed job queue |
| Deploy | Dokploy (self-hosted) | Full control, predictable costs |
| Git | Gitea (git.infra.nkode.tech) | Self-hosted, private |
## Lifecycle Phases
### Phase 1: Ideation
1. Gather requirements from the user (what, who, why)
2. Write a requirements doc at `notes/projects/<app-name>/requirements.md`
3. Research competitors if relevant
4. Get user approval on scope before proceeding
### Phase 2: Scaffold
1. Create project directory at `/home/clawdbot/clawd/<app-name>`
2. Use the template structure from `assets/template/` as a starting point
3. Create Gitea repos (one for monorepo, or separate frontend/backend)
4. Customize schema for the specific app
5. Ensure Hammer service account routes are included
### Phase 3: Development
1. Build features incrementally
2. Write tests from day one
3. Use local docker-compose for development
4. Commit and push frequently
5. Update the task queue dashboard with progress
### Phase 4: Test Deployment
1. Create Dokploy compose deployment
2. Deploy to test subdomain: `test-<app>.donovankelly.xyz`
3. Set environment variables in Dokploy
4. Store all credentials in Bitwarden shared vault
5. Verify basic functionality
### Phase 5: User Review
1. Notify user that test deployment is ready
2. User tests and provides feedback
3. Iterate on feedback (return to Phase 3 as needed)
4. Get explicit approval before going to production
### Phase 6: Production Deployment
1. Create production Dokploy compose
2. Deploy to production subdomain: `<app>.donovankelly.xyz`
3. Configure production environment variables
4. Verify all features work
5. Set up monitoring/health checks
### Phase 7: Maintenance
- Feature requests go through test env first
- DB migrations via Drizzle (`bun run db:push` for dev, `bun run db:migrate` for prod)
- Keep staging synced with prod schema
- Rollback: Dokploy supports redeploying previous builds
## Every App Must Have
- [ ] **BetterAuth with `disableSignUp: true` (invite-only)** — NO EXCEPTIONS. Every app requires authentication. No public-facing pages without login.
- [ ] Hammer service account + `/api/hammer/*` routes (bearer token auth)
- [ ] All API routes behind auth (session or bearer token)
- [ ] Structured logging (console.log with JSON in prod)
- [ ] Unit tests (Vitest for frontend, bun:test for backend)
- [ ] Health check endpoint (`GET /api/health`) — only unauthenticated route allowed
- [ ] docker-compose.yml (local dev)
- [ ] docker-compose.dokploy.yml (production)
- [ ] `.env.example` with all required vars documented
- [ ] All secrets in Bitwarden shared vault
**Auth is not optional.** Even internal tools, dashboards, and single-user apps must have login. If Donovan is the only user, he still logs in. No public read access to any data.
## Environment Variable Naming
Standard env vars across all apps:
```
DATABASE_URL=postgresql://...
PORT=3001
NODE_ENV=production
APP_URL=https://app.<domain>
ALLOWED_ORIGINS=https://app.<domain>
BETTER_AUTH_SECRET=<random>
HAMMER_API_KEY=<random>
RESEND_API_KEY=<if needed>
FROM_EMAIL=<if needed>
```
## Credential Management
- All credentials go in Bitwarden shared vault (org: Hammer's Credentials)
- Create a Bitwarden entry per app: `<App Name> (<domain>)`
- Store HAMMER_API_KEY as a field on the app's BW entry
- Add HAMMER_API_KEY to `~/.clawdbot/.env` as `HAMMER_<APP>_API_KEY`
- Never hardcode secrets, never echo them in chat
## Dokploy Deployment
See `references/deploy.md` for detailed Dokploy deployment steps.
## Rollback Protocol
See `references/rollback.md` for rollback and recovery procedures.
## DB Migration Strategy
See `references/migrations.md` for database migration best practices.
## Legal & Payments
See `references/legal-payments.md` for legal protection and payment processing options.
## Project Documentation
For every app, create in `notes/projects/<app-name>/`:
- `README.md` — overview, stack, status
- `requirements.md` — feature spec
- `architecture.md` — technical design (if complex)
- `feasibility.md` — assessment (if needed)

View File

@@ -0,0 +1,20 @@
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/APP_TEMPLATE
# Server
PORT=3001
NODE_ENV=development
# URLs
APP_URL=http://localhost:5173
ALLOWED_ORIGINS=http://localhost:5173
# Auth
BETTER_AUTH_SECRET=dev-secret-change-in-production
# Hammer Service Account
HAMMER_API_KEY=hammer-dev-key-12345
# Email (optional)
# RESEND_API_KEY=
# FROM_EMAIL=

View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store

View File

@@ -0,0 +1,7 @@
FROM oven/bun:1-alpine
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile 2>/dev/null || bun install
COPY . .
EXPOSE 3001
CMD ["bun", "run", "src/index.ts"]

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View File

@@ -0,0 +1,23 @@
{
"name": "APP_TEMPLATE-api",
"version": "0.0.1",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"test": "bun test"
},
"dependencies": {
"better-auth": "^1.0.0",
"drizzle-orm": "^0.38.0",
"elysia": "^1.2.0",
"postgres": "^3.4.0"
},
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.30.0"
}
}

View File

@@ -0,0 +1,6 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });

View File

@@ -0,0 +1,54 @@
import { pgTable, text, timestamp, boolean, uuid } from 'drizzle-orm/pg-core';
// BetterAuth tables (managed by BetterAuth, do not modify)
export const users = pgTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false),
image: text('image'),
role: text('role').default('user'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const accounts = pgTable('accounts', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
password: text('password'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const verifications = pgTable('verifications', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
// ============================================
// APP-SPECIFIC TABLES — Customize below
// ============================================
// Example:
// export const items = pgTable('items', {
// id: uuid('id').primaryKey().defaultRandom(),
// title: text('title').notNull(),
// userId: text('user_id').references(() => users.id),
// createdAt: timestamp('created_at').defaultNow(),
// updatedAt: timestamp('updated_at').defaultNow(),
// });

View File

@@ -0,0 +1,14 @@
import { Elysia } from 'elysia';
import { cors } from '@elysiajs/cors';
import { hammerRoutes } from './routes/hammer';
const app = new Elysia()
.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:5173'],
credentials: true,
}))
.get('/api/health', () => ({ status: 'ok', timestamp: new Date().toISOString() }))
.use(hammerRoutes)
.listen(Number(process.env.PORT) || 3001);
console.log(`🚀 Server running on port ${app.server?.port}`);

View File

@@ -0,0 +1,17 @@
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '../db';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'pg' }),
emailAndPassword: {
enabled: true,
disableSignUp: true, // Invite-only
},
trustedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:5173'],
advanced: {
crossSubDomainCookies: process.env.NODE_ENV === 'production'
? { domain: process.env.COOKIE_DOMAIN || '' }
: undefined,
},
});

View File

@@ -0,0 +1,38 @@
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
const validateHammerAuth = (authHeader: string | undefined): boolean => {
if (!authHeader) return false;
const token = authHeader.replace('Bearer ', '');
return token === process.env.HAMMER_API_KEY;
};
export const hammerRoutes = new Elysia({ prefix: '/api/hammer' })
.derive(({ request, set }) => {
const authHeader = request.headers.get('authorization');
if (!validateHammerAuth(authHeader)) {
set.status = 401;
throw new Error('Invalid API key');
}
return {};
})
.get('/me', async ({ set }) => {
const hammerUser = await db.query.users.findFirst({
where: eq(users.role, 'service'),
});
if (!hammerUser) {
set.status = 404;
throw new Error('Hammer service account not found');
}
return {
id: hammerUser.id,
name: hammerUser.name,
email: hammerUser.email,
role: hammerUser.role,
};
});
// Add app-specific Hammer routes below

View File

@@ -0,0 +1,27 @@
services:
api:
build:
context: ./api
dockerfile: Dockerfile
restart: unless-stopped
ports:
- 3001
environment:
- DATABASE_URL=${DATABASE_URL}
- PORT=3001
- NODE_ENV=production
- APP_URL=${APP_URL}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- HAMMER_API_KEY=${HAMMER_API_KEY}
command: sh -c 'bun run db:push && bun run src/index.ts'
web:
build:
context: ./web
dockerfile: Dockerfile
restart: unless-stopped
ports:
- 80
depends_on:
- api

View File

@@ -0,0 +1,40 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
ports:
- 5432:5432
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: APP_TEMPLATE
volumes:
- pgdata:/var/lib/postgresql/data
api:
build:
context: ./api
dockerfile: Dockerfile
restart: unless-stopped
ports:
- 3001:3001
env_file: .env
depends_on:
- db
volumes:
- ./api/src:/app/src
web:
build:
context: ./web
dockerfile: Dockerfile
restart: unless-stopped
ports:
- 5173:5173
depends_on:
- api
volumes:
- ./web/src:/app/src
volumes:
pgdata:

View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,17 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://api:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -0,0 +1,28 @@
{
"name": "APP_TEMPLATE-web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vitest": "^2.0.0"
}
}

View File

@@ -0,0 +1,66 @@
# Dokploy Deployment Guide
## Prerequisites
- Dokploy server at 191.101.0.153 (Hostinger KVM2)
- Dokploy API key in Bitwarden ("dokploy api key")
- Gitea repos created at git.infra.nkode.tech
## Compose File Structure
Every app uses `docker-compose.dokploy.yml`:
```yaml
services:
api:
build:
context: ./apps/api # or ./api, ./backend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- 3001
environment:
- DATABASE_URL=${DATABASE_URL}
- PORT=3001
- NODE_ENV=production
- APP_URL=${APP_URL}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- HAMMER_API_KEY=${HAMMER_API_KEY}
command: sh -c 'bun run db:push && bun run src/index.ts'
web:
build:
context: ./apps/web # or ./web, ./frontend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- 80
depends_on:
- api
```
## Deployment Steps
1. Push code to Gitea
2. Create compose in Dokploy:
- Source type: raw compose with git URL
- Set env vars in Dokploy UI
3. Configure domains in Dokploy:
- API: `api.<app>.donovankelly.xyz` → api service port
- Web: `app.<app>.donovankelly.xyz` → web service port
4. Enable HTTPS (Dokploy handles Let's Encrypt)
5. Deploy and verify health check
## Domain Pattern
- Test: `test-<app>.donovankelly.xyz`
- Production: `<app>.donovankelly.xyz`
- API: `api.<app>.donovankelly.xyz` (or `api.todo.donovankelly.xyz`)
- Frontend: `app.<app>.donovankelly.xyz` (or `app.todo.donovankelly.xyz`)
## Environment Variables
Set in Dokploy compose env (not in docker-compose file):
- All `${VAR}` references resolve from Dokploy env settings
- Generate secrets with `openssl rand -hex 32`
- Store everything in Bitwarden immediately after creating

View File

@@ -0,0 +1,47 @@
# Legal Protection & Payments
## Legal (Lean Approach)
### What Pieter Levels Uses
Pieter Levels (maker of Nomad List, Remote OK, Photo AI) keeps it minimal:
- Simple Terms of Service page
- Simple Privacy Policy page
- Generated with free/cheap tools, not expensive services like Termly
### Recommended Approach
1. **Terms of Service** — Use a free generator (TermsFeed free tier, GetTerms.io) or write a simple one
2. **Privacy Policy** — Required if collecting any user data. Free generators available
3. **Cookie Banner** — Only needed if using analytics/tracking cookies
4. **Business Entity** — LLC ($50-150 depending on state) for liability protection
5. **Don't over-engineer** — Until you have paying users, simple legal pages are fine
### When to Upgrade
- Taking payments → need proper ToS with refund policy
- Handling health data → HIPAA considerations
- EU users → GDPR compliance (data export, deletion rights)
- Enterprise clients → may need SOC 2, BAA agreements
## Payments
### Options (Easiest to Hardest)
| Service | Fees | Best For | Setup Time |
|---------|------|----------|------------|
| Lemon Squeezy | 5% + $0.50 | Merchant of record, handles tax/VAT | 1 day |
| Paddle | 5% + $0.50 | Same as Lemon Squeezy, more established | 1 day |
| Stripe | 2.9% + $0.30 | Full control, most flexible | 2-3 days |
| Gumroad | 10% | Digital products, simplest | Hours |
### Recommendation
- **Start with Lemon Squeezy or Paddle** — they handle sales tax, VAT, and act as merchant of record (you don't need a business entity)
- **Move to Stripe** when you need more control or lower fees at scale
- Both have simple JS SDKs and webhook integrations
### Integration Pattern
```
User clicks "Subscribe" → Redirect to payment provider checkout
→ Provider handles payment → Webhook to your API
→ API updates user subscription status in DB
```
Keep payment logic out of your app. Let the provider handle checkout, invoicing, and tax.

View File

@@ -0,0 +1,49 @@
# Database Migration Strategy
## Drizzle ORM Migrations
### Development (Local / Test)
Use `db:push` for rapid iteration:
```bash
bun run db:push
```
This syncs schema directly — fast but destructive. Fine for dev/test.
### Production
Use `db:migrate` with generated migration files:
```bash
bun run db:generate # creates SQL migration file
bun run db:migrate # applies migration
```
### Workflow
1. Change schema in `src/db/schema.ts`
2. Run `bun run db:generate` — creates migration in `drizzle/`
3. Review the generated SQL
4. Write rollback SQL in `drizzle/rollback/` (same filename)
5. Test migration on staging
6. Apply to production
## Safe Migration Practices
### Adding columns
- Always add as nullable or with a default value
- Never add non-nullable columns without defaults to tables with existing data
### Removing columns
- First deploy: stop reading/writing the column in code
- Second deploy: remove the column from schema
- Two-phase approach prevents errors during rolling deploys
### Renaming columns
- Don't rename directly — add new column, migrate data, remove old column
### Adding indexes
- Use `CREATE INDEX CONCURRENTLY` for large tables (avoids locks)
- Drizzle may not generate concurrent indexes — check generated SQL
## Backup Before Migration
Always backup before production migrations:
```bash
pg_dump -Fc $DATABASE_URL > backup-$(date +%Y%m%d-%H%M%S).dump
```

View File

@@ -0,0 +1,43 @@
# Rollback & Recovery
## Levels of Rollback
### 1. Code Rollback (Most Common)
- Dokploy keeps previous builds
- Redeploy previous compose version from Dokploy UI
- Or: `git revert` the breaking commit, push, redeploy
### 2. Database Rollback
- Drizzle doesn't auto-generate down migrations
- For schema changes, write explicit rollback SQL before deploying
- Keep a `migrations/rollback/` directory with undo scripts
- For data issues: restore from Dokploy Postgres backup
### 3. Full Rollback
- Dokploy allows complete service redeployment from any previous state
- Database: restore from backup
- Last resort: rebuild from Gitea source at known-good commit
## Pre-Deploy Checklist
Before any production deployment:
- [ ] Feature works in test environment
- [ ] User has approved in test
- [ ] DB migration tested (if schema changed)
- [ ] Rollback SQL written (if schema changed)
- [ ] Health check passes after deploy
## Staging Environment
- Staging should mirror production schema
- Periodically sync staging DB schema with prod
- Never sync prod DATA to staging (privacy)
- Test migrations on staging before prod
## If Something Breaks in Prod
1. **Assess severity** — is the app down or is it a bug?
2. **If app is down** — redeploy previous Dokploy build immediately
3. **If it's a bug** — fix in dev, test, deploy fix
4. **If DB is corrupted** — restore from backup, investigate cause
5. **Notify user** with what happened and what was done

View File

@@ -0,0 +1,55 @@
#!/bin/bash
# Scaffold a new app from the standard template
# Usage: scaffold.sh <app-name> [--api-only]
#
# Creates project structure, initializes git, and pushes to Gitea.
set -euo pipefail
APP_NAME="${1:?Usage: scaffold.sh <app-name>}"
API_ONLY="${2:-}"
BASE_DIR="/home/clawdbot/clawd"
PROJECT_DIR="$BASE_DIR/$APP_NAME"
SKILL_DIR="$(dirname "$(realpath "$0")")/.."
TEMPLATE_DIR="$SKILL_DIR/assets/template"
GITEA_URL="https://git.infra.nkode.tech"
if [ -d "$PROJECT_DIR" ]; then
echo "Error: $PROJECT_DIR already exists"
exit 1
fi
echo "🔨 Scaffolding $APP_NAME..."
# Create project structure
mkdir -p "$PROJECT_DIR"
cp -r "$TEMPLATE_DIR/api" "$PROJECT_DIR/api"
if [ "$API_ONLY" != "--api-only" ]; then
cp -r "$TEMPLATE_DIR/web" "$PROJECT_DIR/web"
fi
# Copy root files
cp "$TEMPLATE_DIR/docker-compose.yml" "$PROJECT_DIR/"
cp "$TEMPLATE_DIR/docker-compose.dokploy.yml" "$PROJECT_DIR/"
cp "$TEMPLATE_DIR/.env.example" "$PROJECT_DIR/"
cp "$TEMPLATE_DIR/.gitignore" "$PROJECT_DIR/"
# Replace placeholder app name
find "$PROJECT_DIR" -type f \( -name "*.ts" -o -name "*.json" -o -name "*.yml" -o -name "*.md" -o -name "*.env*" \) \
-exec sed -i "s/APP_TEMPLATE/$APP_NAME/g" {} +
# Initialize git
cd "$PROJECT_DIR"
git init
git add -A
git commit -m "Initial scaffold from app-builder template"
echo ""
echo "✅ Project scaffolded at $PROJECT_DIR"
echo ""
echo "Next steps:"
echo " 1. Create Gitea repo: $GITEA_URL/hammer/$APP_NAME"
echo " 2. git remote add origin $GITEA_URL/hammer/$APP_NAME.git"
echo " 3. git push -u origin main"
echo " 4. Customize the schema in api/src/db/schema.ts"
echo " 5. Run: cd $PROJECT_DIR && docker compose up"