Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
node_modules
.next
data
.env
.env.*
Expand Down
39 changes: 32 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,12 +355,10 @@ All previously listed follow-ups have been shipped. See the live issue list for
- File-based Persistence ✅ — `.sin-webui/` workspace with JSON stores (settings, workspaces, memories, projects)

### Open Issues (check before starting any new task)
- **#51** — feat: Implement Better Auth multi-user authentication system
- **#52** — feat: Undo/Redo history for Design Mode
- **#53** — fix: Eliminate Turbopack/NFT build warning
- **#54** — feat: Screenshot area capture (⌘+Drag) in Design Mode
- **#55** — feat: Real Vercel deployment behind Publish button
- Real Vercel-Deploy behind the Publish button — out of scope (real deploys go through Docker + Cloudflare Tunnel, see `PLAN_DEPLOY.md`) — superseded by #55
- **#59** — fix(auth): resolve 401 on all /api/* routes after Better Auth + Kysely integration
- **#60** — fix(build): eliminate the last Turbopack NFT warning in design-edit route
- **#61** — feat(design-mode): wire ⌘Z / ⌘⇧Z keyboard shortcuts for Undo/Redo
- **#62** — ci: verify ceo-audit + tsc-check are green on main @ 30de716

Check the live issue list before starting any new task.

Expand Down Expand Up @@ -390,6 +388,33 @@ auf Redirects umgestellt werden. Ein PR ist erst fertig, wenn die
betroffenen Routen im Browser (Screenshot) verifiziert wurden — ein
grüner tsc/build-Check ist KEIN Beweis für korrektes Rendering.

## §5.10 No Orphaned Components

After every UI refactoring, verify that all old entry points are migrated or
redirected. A PR is only done when the affected routes are verified in the
browser (screenshot) — a green tsc/build check is NO proof of correct
rendering. Use `sin-discover` to detect orphaned components (exported but not
imported anywhere in nav/sidebar). After wiring a new component, delete the
legacy file in the same PR. Never leave dangling components.

## §5.11 Routing Protection

When deploying to Docker + Cloudflare Tunnel on macOS:

- Port 3000 may be occupied by other processes (whatsapp-bridge, other dev
servers) — always check with `lsof -i :3000` before assuming port is free
- Remap Docker container to `3100:3000` in docker-compose.yml to avoid conflicts
- Update `~/.cloudflared/config-sin-code-webui.yml` to point to `localhost:3100`
- Restart cloudflared tunnel after config changes:
```bash
pkill -f "cloudflared tunnel"
nohup cloudflared tunnel --config ~/.cloudflared/config-sin-code-webui.yml run sin-code-webui &
```
- Verify with `curl -s https://sincode-webui.delqhi.com/api/health` — expect
Next.js JSON, NOT Express "Invalid Host header"
- If live domain returns Express headers, a local process (not the container)
is hijacking the tunnel

---

Last updated: aligned with main @ `86872a4` (post deploy work).
Last updated: aligned with main @ `30de716` (post #58 merge).
11 changes: 8 additions & 3 deletions app/api/settings/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import crypto from "crypto"
import { guardRequest } from "@/lib/sin/run"
import { getSession } from "@/lib/session"

const BASE = path.join(process.cwd(), ".sin-webui")
let _base: string | null = null
// @turbopack-disable-next-line
function base(): string {
if (!_base) _base = path.join(/*turbopackIgnore: true*/ process.cwd(), ".sin-webui")
return _base
}

function mcpFile(userId: string): string {
if (userId === "global") return path.join(BASE, "mcp-connections.json")
if (userId === "global") return path.join(base(), "mcp-connections.json")
const safe = userId.replace(/[^a-zA-Z0-9_-]/g, "_")
return path.join(BASE, "users", safe, "mcp-connections.json")
return path.join(base(), "users", safe, "mcp-connections.json")
}

export interface McpConnection {
Expand Down
11 changes: 8 additions & 3 deletions app/api/settings/workspace/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ import path from "path"
import { guardRequest } from "@/lib/sin/run"
import { getSession } from "@/lib/session"

const BASE = path.join(process.cwd(), ".sin-webui")
let _base: string | null = null
// @turbopack-disable-next-line
function base(): string {
if (!_base) _base = path.join(/*turbopackIgnore: true*/ process.cwd(), ".sin-webui")
return _base
}

function workspaceFile(userId: string): string {
if (userId === "global") return path.join(BASE, "workspace.json")
if (userId === "global") return path.join(base(), "workspace.json")
const safe = userId.replace(/[^a-zA-Z0-9_-]/g, "_")
return path.join(BASE, "users", safe, "workspace.json")
return path.join(base(), "users", safe, "workspace.json")
}

interface Workspace {
Expand Down
15 changes: 10 additions & 5 deletions app/api/workspace/design-edit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import { promises as fs } from "fs"
import path from "path"
import { pushEntry } from "@/lib/workspace/design-history"

const ROOT = process.env.SIN_WORKSPACE_DIR ?? process.cwd()
let _root: string | null = null
// @turbopack-disable-next-line
function root(): string {
if (!_root) _root = /*turbopackIgnore: true*/ (process.env.SIN_WORKSPACE_DIR ?? process.cwd())
return _root
}

function safeResolve(rel: string): string {
const resolved = path.resolve(ROOT, "." + path.sep + rel)
if (!resolved.startsWith(ROOT)) throw new Error("Invalid path")
const resolved = /*turbopackIgnore: true*/ path.resolve(root(), "." + path.sep + rel)
if (!resolved.startsWith(root())) throw new Error("Invalid path")
return resolved
}

Expand Down Expand Up @@ -36,7 +41,7 @@ export async function POST(req: Request) {

let content: string
try {
content = await fs.readFile(abs, "utf8")
content = await /*turbopackIgnore: true*/ fs.readFile(abs, "utf8")
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 })
}
Expand All @@ -61,7 +66,7 @@ export async function POST(req: Request) {
)
}

await fs.writeFile(abs, lines.join("\n"), "utf8")
await /*turbopackIgnore: true*/ fs.writeFile(abs, lines.join("\n"), "utf8")
const relativeFilePath = file
const tagName = "div" // or get from the request context
await pushEntry({
Expand Down
15 changes: 10 additions & 5 deletions app/api/workspace/files/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { NextResponse } from "next/server"
import { promises as fs } from "fs"
import path from "path"

const ROOT = process.env.SIN_WORKSPACE_DIR ?? process.cwd()
let _root: string | null = null
// @turbopack-disable-next-line
function root(): string {
if (!_root) _root = process.env.SIN_WORKSPACE_DIR ?? (/*turbopackIgnore: true*/ process.cwd())
return _root
}

const IGNORE = new Set(["node_modules", ".git", ".next", ".sin-webui", "dist"])
const MAX_FILE_SIZE = 512 * 1024
Expand All @@ -15,8 +20,8 @@ interface TreeNode {
}

function safeResolve(rel: string): string {
const resolved = path.resolve(ROOT, "." + path.sep + rel)
if (!resolved.startsWith(ROOT)) throw new Error("Invalid path")
const resolved = /*turbopackIgnore: true*/ path.resolve(root(), "." + path.sep + rel)
if (!resolved.startsWith(root())) throw new Error("Invalid path")
return resolved
}

Expand All @@ -31,7 +36,7 @@ async function buildTree(dir: string, relBase = ""): Promise<TreeNode[]> {
name: entry.name,
path: relPath,
type: "dir",
children: await buildTree(path.join(dir, entry.name), relPath),
children: await buildTree(/*turbopackIgnore: true*/ path.join(dir, entry.name), relPath),
})
} else {
nodes.push({ name: entry.name, path: relPath, type: "file" })
Expand Down Expand Up @@ -60,5 +65,5 @@ export async function GET(req: Request) {
}
}

return NextResponse.json({ nodes: await buildTree(ROOT) })
return NextResponse.json({ nodes: await buildTree(root()) })
}
9 changes: 7 additions & 2 deletions app/api/workspace/screenshot/[filename]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import path from 'node:path'
import { promises as fs } from 'node:fs'

const DIR = path.join(process.cwd(), '.sin-webui', 'screenshots')
let _dir: string | null = null
// @turbopack-disable-next-line
function dir(): string {
if (!_dir) _dir = /*turbopackIgnore: true*/ path.join(process.cwd(), '.sin-webui', 'screenshots')
return _dir
}

export async function GET(
_req: Request,
Expand All @@ -10,7 +15,7 @@ export async function GET(
const { filename } = await params
const safe = path.basename(filename) // path-traversal guard
try {
const buffer = await fs.readFile(path.join(DIR, safe))
const buffer = await fs.readFile(/*turbopackIgnore: true*/ path.join(dir(), safe))
return new Response(new Uint8Array(buffer), {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'private, max-age=31536000' },
})
Expand Down
27 changes: 19 additions & 8 deletions app/api/workspace/screenshot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ import path from 'node:path'
import { promises as fs } from 'node:fs'
import { randomUUID } from 'node:crypto'

const DIR = path.join(process.cwd(), '.sin-webui', 'screenshots')
const INDEX = path.join(process.cwd(), '.sin-webui', 'screenshots.json')
let _dir: string | null = null
// @turbopack-disable-next-line
function dir(): string {
if (!_dir) _dir = path.join(/*turbopackIgnore: true*/ process.cwd(), '.sin-webui', 'screenshots')
return _dir
}

let _index: string | null = null
// @turbopack-disable-next-line
function indexPath(): string {
if (!_index) _index = path.join(/*turbopackIgnore: true*/ process.cwd(), '.sin-webui', 'screenshots.json')
return _index
}

type Meta = { id: string; filename: string; createdAt: string; size: number }

async function readIndex(): Promise<Meta[]> {
try {
return JSON.parse(await fs.readFile(INDEX, 'utf8')) as Meta[]
return JSON.parse(await fs.readFile(indexPath(), 'utf8')) as Meta[]
} catch {
return []
}
Expand All @@ -29,14 +40,14 @@ export async function POST(req: Request) {
if (buffer.length > 10 * 1024 * 1024) {
return NextResponse.json({ error: 'Screenshot too large' }, { status: 413 })
}
await fs.mkdir(DIR, { recursive: true })
await fs.mkdir(dir(), { recursive: true })
const id = randomUUID()
const filename = `${Date.now()}-${id.slice(0, 8)}.png`
await fs.writeFile(path.join(DIR, filename), buffer)
await fs.writeFile(/*turbopackIgnore: true*/ path.join(dir(), filename), buffer)

const index = await readIndex()
index.unshift({ id, filename, createdAt: new Date().toISOString(), size: buffer.length })
await fs.writeFile(INDEX, JSON.stringify(index.slice(0, 200), null, 2))
await fs.writeFile(indexPath(), JSON.stringify(index.slice(0, 200), null, 2))

return NextResponse.json({ id, url: `/api/workspace/screenshot/${filename}` })
}
Expand All @@ -46,8 +57,8 @@ export async function DELETE(req: Request) {
const index = await readIndex()
const meta = index.find((m) => m.id === id)
if (meta) {
await fs.rm(path.join(DIR, meta.filename), { force: true })
await fs.writeFile(INDEX, JSON.stringify(index.filter((m) => m.id !== id), null, 2))
await fs.rm(/*turbopackIgnore: true*/ path.join(dir(), meta.filename), { force: true })
await fs.writeFile(indexPath(), JSON.stringify(index.filter((m) => m.id !== id), null, 2))
}
return NextResponse.json({ ok: true })
}
9 changes: 7 additions & 2 deletions app/api/workspace/versions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ import { promisify } from "util"
import { execFile } from "child_process"

const execFileAsync = promisify(execFile)
const ROOT = process.env.SIN_WORKSPACE_DIR ?? process.cwd()
let _root: string | null = null
// @turbopack-disable-next-line
function root(): string {
if (!_root) _root = process.env.SIN_WORKSPACE_DIR ?? (/*turbopackIgnore: true*/ process.cwd())
return _root
}

export async function GET() {
try {
const { stdout } = await execFileAsync(
"git",
["log", "--pretty=format:%H|%cI|%s", "-n", "50"],
{ cwd: ROOT },
{ cwd: root() },
)
const versions = stdout
.trim()
Expand Down
23 changes: 18 additions & 5 deletions components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,18 @@ const json = await res.json()
setShared(Boolean(json.data?.slug))
}

async function handleExport() {
const url = `${window.location.origin}/chat/${chat.id}`
async function handleExport(format: 'md' | 'json') {
const res = await fetch(`/api/chats/${chat.id}/export?format=${format}`)
if (!res.ok) return
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${chat.label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.html`
a.href = objectUrl
a.download = `${chat.label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.${format}`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(objectUrl)
}

function handleMoveToNewProject() {
Expand All @@ -108,7 +112,16 @@ return (
<DropdownMenuContent align="start" side="right" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleShare}><Share2 className="size-4" />{shared ? 'Unshare' : 'Share (copy link)'}</DropdownMenuItem>
<DropdownMenuItem onClick={handleExport}><FileText className="size-4" />Export as HTML</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FileText className="size-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleExport('md')}>As Markdown</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('json')}>As JSON</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Move to Project</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
Expand Down
28 changes: 26 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
services:
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
- POSTGRES_DB=sin_webui
volumes:
- postgres-data:/var/lib/postgresql/data
- ./scripts/better-auth-schema.sql:/docker-entrypoint-initdb.d/010-better-auth-schema.sql:ro
- ./scripts/001_init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d sin_webui"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s

webui:
build: .
ports:
- "3000:3000"
- "3100:3000"
depends_on:
postgres:
condition: service_healthy
environment:
- AI_GATEWAY_API_KEY=${AI_GATEWAY_API_KEY}
- SIN_CHAT_MODEL=${SIN_CHAT_MODEL:-openai/gpt-5-mini}
- SIN_CODE_BIN=/usr/local/bin/sin-code
- DATABASE_URL=postgresql://app:${POSTGRES_PASSWORD:-changeme}@postgres:5432/sin_webui?sslmode=disable
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-changeme-insecure-dev-secret-32chars-min}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3100}
volumes:
- sin-webui-data:/app/.sin-webui

volumes:
sin-webui-data:
postgres-data:
sin-webui-data:
Loading
Loading