Skip to content
Open
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
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
node_modules
.next
.git
data
dist
build
out
.turbo
.vercel
electron/dist
*.log
.env
.env.local
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Claudable server config — copy to .env on your host (DO NOT COMMIT).

# --- Agent auth: run claude -p with your Claude subscription (no API billing) ---
# Generate once with `claude setup-token` on a logged-in machine, paste here.
CLAUDE_CODE_OAUTH_TOKEN=

# --- Git provider: 'github' (default) or 'gitea' (self-hosted) ---
GIT_PROVIDER=github
GIT_API_BASE_URL=https://git.example.com/api/v1
GIT_HTTP_BASE=https://git.example.com
GIT_ORG=your-org
GIT_DEPLOY_DOMAIN=example.com
# Git API token (write:repository, write:organization). Used for repo create + push.
GIT_TOKEN=

# --- App / public URL ---
DATABASE_URL=file:../data/cc.db
PROJECTS_DIR=./data/projects
WEB_PORT=3700
# Public URL of this Claudable instance (behind your reverse proxy).
NEXT_PUBLIC_APP_URL=https://claudable.example.com
# Template for per-project preview URLs ({port} is substituted).
PREVIEW_URL_TEMPLATE=https://preview-{port}.example.com
# How your reverse proxy reaches host-network containers (host gateway IP, or
# host.docker.internal on Docker Desktop).
DEPLOY_HOST_GATEWAY=host.docker.internal
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Claudable — self-hosted AI web builder, running the agent via `claude -p`
# (Claude Code CLI / Agent SDK) with subscription auth (CLAUDE_CODE_OAUTH_TOKEN).
FROM node:22-bookworm-slim

# Tooling the agent needs at runtime: git (push), ripgrep (claude search), ca-certs.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates ripgrep curl \
&& rm -rf /var/lib/apt/lists/*

# Claude Code CLI on PATH so the Agent SDK can spawn `claude` headless.
RUN npm install -g @anthropic-ai/claude-code

# Run as the non-root `node` user (uid 1000, matches the host volume owner) — Claude
# Code refuses --dangerously-skip-permissions as root. Building entirely as `node`
# (with COPY --chown) means files are created node-owned, so NO slow recursive chown.
WORKDIR /app
RUN chown node:node /app
USER node

# Pre-create ~/.claude as node so a bind-mount at ~/.claude/skills doesn't make
# Docker create the parent as root (which blocks the agent writing session-env).
RUN mkdir -p /home/node/.claude

# Install deps (cached on lockfile). --ignore-scripts skips electron/postinstall.
COPY --chown=node:node package*.json ./
RUN npm ci --ignore-scripts
COPY --chown=node:node prisma ./prisma
RUN npx prisma generate

# Build the Next.js app.
COPY --chown=node:node . .
RUN npm run build

ENV NODE_ENV=production
ENV PORT=3700
ENV WEB_PORT=3700

# Ensure the SQLite schema exists on the mounted volume, then start.
CMD ["sh", "-c", "npx prisma db push --skip-generate && npx next start -p ${WEB_PORT}"]
706 changes: 433 additions & 273 deletions app/[project_id]/chat/page.tsx

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion app/api/assets/[project_id]/logo/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ export async function POST(request: Request, { params }: RouteContext) {
return NextResponse.json({ success: false, error: 'Project not found' }, { status: 404 });
}

const body = await request.json();
const body = await request.json().catch(() => null);
const b64 = typeof body?.b64_png === 'string' ? body.b64_png : null;
if (!b64) {
return NextResponse.json({ success: false, error: 'b64_png is required' }, { status: 400 });
}
// Cap the payload (~6MB base64 ≈ 4.5MB binary) to avoid memory/disk abuse.
if (b64.length > 6 * 1024 * 1024) {
return NextResponse.json({ success: false, error: 'Logo too large (max ~4.5MB)' }, { status: 413 });
}

const buffer = Buffer.from(b64, 'base64');
const assetsPath = path.join(PROJECTS_DIR_ABSOLUTE, project_id, 'assets');
Expand Down
48 changes: 38 additions & 10 deletions app/api/chat/[project_id]/act/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { serializeMessage } from '@/lib/serializers/chat';
import {
upsertUserRequest,
markUserRequestAsProcessing,
markUserRequestAsFailed,
} from '@/lib/services/user-requests';

interface RouteContext {
Expand All @@ -49,16 +50,22 @@ function resolveAssetsPath(projectId: string): string {
}

function ensureAbsoluteAssetPath(projectId: string, inputPath: string): string {
const projectBase = path.join(PROJECTS_DIR_ABSOLUTE, projectId);
const normalized = path.normalize(inputPath);
if (path.isAbsolute(normalized)) {
return normalized;
}
const resolvedFromCwd = path.resolve(process.cwd(), normalized);
if (resolvedFromCwd.startsWith(PROJECTS_DIR_ABSOLUTE)) {
return resolvedFromCwd;
// Absolute paths are kept as-is; relative paths resolve under the project.
const candidate = path.isAbsolute(normalized)
? normalized
: path.resolve(projectBase, normalized);
// Security: the path MUST stay inside the projects directory. Without this an
// attacker could pass an absolute path like "/etc/passwd" (or "../../..") and
// the app would stat/copy arbitrary files.
if (
candidate !== PROJECTS_DIR_ABSOLUTE &&
!candidate.startsWith(PROJECTS_DIR_ABSOLUTE + path.sep)
) {
throw new Error('Asset path is outside the projects directory');
}
const projectBase = path.join(PROJECTS_DIR_ABSOLUTE, projectId);
return path.resolve(projectBase, normalized);
return candidate;
}

function resolveProjectRoot(projectId: string, repoPath?: string | null): string {
Expand Down Expand Up @@ -289,6 +296,13 @@ export async function POST(request: NextRequest, { params }: RouteContext) {
getDefaultModelForCli(cliPreference);
const selectedModel = normalizeModelId(cliPreference, selectedModelRaw);

const thinkingModeRaw =
coerceString(body.thinkingMode) ?? coerceString(legacyBody['thinking_mode']);
const thinkingMode: 'off' | 'auto' | 'forced' =
thinkingModeRaw === 'off' || thinkingModeRaw === 'forced'
? thinkingModeRaw
: 'auto';

const conversationId =
coerceString(body.conversationId) ?? coerceString(legacyBody['conversation_id']);

Expand Down Expand Up @@ -437,15 +451,29 @@ export async function POST(request: NextRequest, { params }: RouteContext) {
? project.activeCursorSessionId || undefined
: undefined;

executor(
// thinkingMode is only consumed by the Claude executor; other CLIs
// ignore the extra argument. Cast to avoid a union-arity type error.
(executor as (...args: unknown[]) => Promise<void>)(
project_id,
projectPath,
finalInstruction,
selectedModel,
sessionId,
requestId,
).catch((error) => {
cliPreference === 'claude' ? thinkingMode : undefined,
).catch(async (error) => {
console.error('[API] Failed to execute AI:', error);
// If the executor rejected outright, its own finally never marked the
// request terminal — do it here so the row can't get stuck in an
// active status and permanently lock the project.
if (requestId) {
await markUserRequestAsFailed(
requestId,
error instanceof Error ? error.message : 'AI execution failed',
).catch((markError) => {
console.error('[API] Failed to mark request failed:', markError);
});
}
});
}

Expand Down
2 changes: 1 addition & 1 deletion app/api/chat/[project_id]/cli-preference/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function GET(_request: NextRequest, { params }: RouteContext) {
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
const body = (await request.json().catch(() => null)) ?? {};
if (!body || typeof body !== 'object') {
return NextResponse.json(
{ success: false, error: 'Invalid payload' },
Expand Down
7 changes: 5 additions & 2 deletions app/api/chat/[project_id]/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ export async function GET(
try {
const { project_id } = await params;
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
// Clamp pagination so a NaN / negative / huge value can't break the query.
const rawLimit = parseInt(searchParams.get('limit') || '50', 10);
const rawOffset = parseInt(searchParams.get('offset') || '0', 10);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 500) : 50;
const offset = Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : 0;

const [messages, totalCount] = await Promise.all([
getMessagesByProjectId(project_id, limit, offset),
Expand Down
2 changes: 1 addition & 1 deletion app/api/env/[project_id]/[key]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface RouteContext {
export async function PUT(request: NextRequest, { params }: RouteContext) {
try {
const { project_id, key } = await params;
const body = await request.json();
const body = (await request.json().catch(() => null)) ?? {};
if (typeof body?.value !== 'string') {
return NextResponse.json(
{ success: false, error: 'value must be a string' },
Expand Down
2 changes: 1 addition & 1 deletion app/api/env/[project_id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function GET(_request: NextRequest, { params }: RouteContext) {
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
const body = (await request.json().catch(() => null)) ?? {};
if (!body?.key || typeof body.key !== 'string') {
return NextResponse.json(
{ success: false, error: 'key is required' },
Expand Down
2 changes: 1 addition & 1 deletion app/api/env/[project_id]/upsert/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface RouteContext {
export async function POST(request: NextRequest, { params }: RouteContext) {
try {
const { project_id } = await params;
const body = await request.json();
const body = (await request.json().catch(() => null)) ?? {};
if (!body?.key || typeof body.key !== 'string') {
return NextResponse.json(
{ success: false, error: 'key is required' },
Expand Down
20 changes: 20 additions & 0 deletions app/api/git/provider/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { getGitProviderConfig } from '@/lib/services/git-provider';

/**
* Exposes the server's git provider configuration to the client so the Publish
* UI can adapt (e.g. the self-hosted Gitea flow deploys via the Actions runner
* and does not need Vercel).
*/
export async function GET() {
const cfg = getGitProviderConfig();
return NextResponse.json({
success: true,
provider: cfg.provider,
deployDomain: cfg.deployDomain,
org: cfg.org,
});
}

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
2 changes: 1 addition & 1 deletion app/api/github/create-repo/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createRepository, getGithubUser } from '@/lib/services/github';

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const body = (await request.json().catch(() => null)) ?? {};
if (!body || typeof body !== 'object') {
return NextResponse.json({ success: false, error: 'Invalid payload' }, { status: 400 });
}
Expand Down
33 changes: 33 additions & 0 deletions app/api/projects/[project_id]/deploy/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { getDeployRunStatus } from '@/lib/services/github';

interface RouteContext {
params: Promise<{ project_id: string }>;
}

/**
* Real deployment status for the self-hosted (Gitea Actions) publish flow:
* the latest CI run's state (queued/running/success/failure), a link to the
* run log, and the live URL. Polled by the Publish UI.
*/
export async function GET(_request: Request, { params }: RouteContext) {
try {
const { project_id } = await params;
const status = await getDeployRunStatus(project_id);
const res = NextResponse.json({ success: true, ...status });
res.headers.set('Cache-Control', 'no-store');
return res;
} catch (error) {
return NextResponse.json(
{
success: false,
error: 'Failed to get deploy status',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 },
);
}
}

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
Loading