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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ CODE_OUTPUT_DIR=generated-code
# Use a URL-encoded password if it contains @ (e.g. p%40ssword for p@ssword).
# BLUEPRINT_GENERATED_DATABASE_URL=postgresql://user:pass@localhost:5432/dbname

# --- Infra auto-provisioning (kickoff) ---
# Postgres/Redis: one Dokploy container per generated app. Set both to enable.
# DOKPLOY_URL=https://dokploy.example.com
# DOKPLOY_TOKEN=
#
# S3 object storage: ONE shared bucket for all generated apps; each app gets an
# isolated folder (key prefix = app slug). Set BLUEPRINT_S3_BUCKET to enable;
# the kickoff "service detector" then auto-wires AWS_S3_* into the app's .env
# whenever the PRD/TRD implies file/image/media upload. Works with AWS S3 or any
# S3-compatible store (MinIO, Cloudflare R2) via the optional endpoint vars.
# BLUEPRINT_S3_BUCKET=my-shared-test-bucket
# BLUEPRINT_S3_ACCESS_KEY_ID=
# BLUEPRINT_S3_SECRET_ACCESS_KEY=
# BLUEPRINT_S3_REGION=us-east-1
# BLUEPRINT_S3_ENDPOINT= # e.g. https://<account>.r2.cloudflarestorage.com (S3-compatible only)
# BLUEPRINT_S3_FORCE_PATH_STYLE= # set to 1 for MinIO / path-style addressing

# --- Project kick-off: Git / Jira (optional; webhooks OR direct API) ---
# If a webhook URL is set, it is used instead of the direct API for that integration.
#
Expand Down
7 changes: 6 additions & 1 deletion src/app/api/agents/coding/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
resolveBackendPort,
upsertBackendPrivyAppIdMirror,
resolvePrivyAppIdMirrorFromFilledResources,
upsertEnvVars,
} from "@/lib/pipeline/generated-code-env";
import {
normalizeProjectTier,
Expand All @@ -48,6 +49,7 @@ import {
readKickoffInfraMetadata,
databaseUrlFrom,
redisUrlFrom,
s3EnvFrom,
} from "@/lib/pipeline/kickoff-infra";
import {
readResourceRequirements,
Expand Down Expand Up @@ -1401,7 +1403,10 @@ export async function POST(request: NextRequest) {
const withRedisUrl = redisUrlForEnv
? upsertRedisUrlEnv(withDbUrl, redisUrlForEnv)
: withDbUrl;
const withJwt = upsertJwtEnvVars(withRedisUrl);
// S3 credential bundle (shared bucket + per-app folder) from kickoff infra.
const s3Env = s3EnvFrom(kickoffInfra);
const withS3 = s3Env ? upsertEnvVars(withRedisUrl, s3Env) : withRedisUrl;
const withJwt = upsertJwtEnvVars(withS3);
const withPort = upsertBackendPortEnv(withJwt);
const withResources = upsertResourceEnvVars(withPort, backendResources);
const privyMirror =
Expand Down
27 changes: 18 additions & 9 deletions src/components/kickoff/InfraSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from "react";

export interface InfraServiceMeta {
kind: "postgres" | "redis";
kind: "postgres" | "redis" | "s3";
appName: string;
externalPort: number;
publicUrl: string;
Expand All @@ -27,11 +27,13 @@ interface Props {
const KIND_ICON: Record<InfraServiceMeta["kind"], string> = {
postgres: "🐘",
redis: "🟥",
s3: "🪣",
};

const KIND_LABEL: Record<InfraServiceMeta["kind"], string> = {
postgres: "Postgres",
redis: "Redis",
s3: "S3",
};

type PingStatus =
Expand Down Expand Up @@ -72,9 +74,12 @@ export default function InfraSection({ infra, dokployBaseUrl }: Props) {
setPings((p) => ({ ...p, [key]: result }));
}, []);

// Initial probe — fires once per service when the panel mounts.
// Initial probe — fires once per service when the panel mounts. S3 is an
// external bucket reached over HTTPS with credentials, not a pingable TCP
// endpoint, so we skip the reachability probe for it.
useEffect(() => {
for (const svc of services) {
if (svc.kind === "s3") continue;
const key = `${svc.kind}-${svc.appName}`;
runPing(key, svc.publicUrl);
}
Expand Down Expand Up @@ -156,13 +161,17 @@ export default function InfraSection({ infra, dokployBaseUrl }: Props) {
<span className="text-[13px] font-semibold text-zinc-900">
{KIND_LABEL[svc.kind]}
</span>
<span className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[11px] text-zinc-700">
:{svc.externalPort}
</span>
<PingBadge
status={ping}
onRetry={() => runPing(key, svc.publicUrl)}
/>
{svc.kind !== "s3" && (
<span className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[11px] text-zinc-700">
:{svc.externalPort}
</span>
)}
{svc.kind !== "s3" && (
<PingBadge
status={ping}
onRetry={() => runPing(key, svc.publicUrl)}
/>
)}
</div>
<button
type="button"
Expand Down
19 changes: 15 additions & 4 deletions src/lib/agents/infra/service-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ Output STRICT JSON — no prose, no markdown fences:
{
"needsPostgres": true|false,
"needsRedis": true|false,
"needsS3": true|false,
"evidence": [
{ "service": "postgres"|"redis", "quote": "<≤120 chars from the docs>" }
{ "service": "postgres"|"redis"|"s3", "quote": "<≤120 chars from the docs>" }
]
}

Expand All @@ -30,24 +31,34 @@ Rules:
if it's only mentioned in passing or compared in a tech selection discussion.
- Redis is needed only when the project requires cache, session store, queue,
rate-limiting, or pub/sub. Mentioning "Redis" in passing is not enough.
- S3 (object storage) is needed only when the project stores/serves user files,
images, avatars, documents, media, or attachments — i.e. binary blobs that
don't belong in Postgres. File upload / download / presigned-URL flows count.
A purely text/CRUD app with no file handling does NOT need S3.
- Every "true" decision MUST have at least one matching evidence entry.
- "false" decisions should have no evidence entries for that service.
- Quotes must come from the input docs verbatim (or trimmed to ≤120 chars).`;

const EvidenceSchema = z.object({
service: z.enum(["postgres", "redis"]),
service: z.enum(["postgres", "redis", "s3"]),
quote: z.string().min(1).max(200),
});

const ServiceDecisionSchema = z
.object({
needsPostgres: z.boolean(),
needsRedis: z.boolean(),
needsS3: z.boolean().default(false),
evidence: z.array(EvidenceSchema).default([]),
})
.superRefine((d, ctx) => {
for (const svc of ["postgres", "redis"] as const) {
const flag = svc === "postgres" ? d.needsPostgres : d.needsRedis;
const flagByService = {
postgres: d.needsPostgres,
redis: d.needsRedis,
s3: d.needsS3,
} as const;
for (const svc of ["postgres", "redis", "s3"] as const) {
const flag = flagByService[svc];
const has = d.evidence.some((e) => e.service === svc);
if (flag && !has) {
ctx.addIssue({
Expand Down
1 change: 1 addition & 0 deletions src/lib/deploy/__tests__/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ vi.mock("@/lib/pipeline/kickoff-infra", () => ({
readKickoffInfraMetadata: vi.fn(),
internalDatabaseUrlFrom: vi.fn(),
internalRedisUrlFrom: vi.fn(),
s3EnvFrom: vi.fn(),
persistComposeOnInfra: vi.fn(),
}));
vi.mock("../dokploy", () => ({
Expand Down
9 changes: 9 additions & 0 deletions src/lib/deploy/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
readKickoffInfraMetadata,
internalDatabaseUrlFrom,
internalRedisUrlFrom,
s3EnvFrom,
persistComposeOnInfra,
} from "@/lib/pipeline/kickoff-infra";
import { createDokployProject, createDokployCompose, createDokployDomain, updateDokployCompose, deployDokployCompose, pollDeployStatus, getDokployCompose } from "./dokploy";
Expand Down Expand Up @@ -75,6 +76,7 @@ async function buildDokployEnv(
generatedCodePath: string,
databaseUrl: string,
redisInternalUrl: string | null,
s3Env: Record<string, string> | null,
): Promise<{ env: string; count: number; sourcedFromBackendEnv: boolean }> {
const backendEnvPath = path.resolve(generatedCodePath, "backend", ".env");
const fileEnv = await readEnvFile(backendEnvPath);
Expand All @@ -83,6 +85,11 @@ async function buildDokployEnv(
// local `pnpm dev`); deployed containers must use internal DNS.
fileEnv.DATABASE_URL = databaseUrl;
if (redisInternalUrl) fileEnv.REDIS_URL = redisInternalUrl;
// S3 is a shared external bucket — same access from local dev and the
// deployed container, so the values are identical (no internal/public split).
if (s3Env) {
for (const [k, v] of Object.entries(s3Env)) fileEnv[k] = v;
}
const lines: string[] = [];
for (const [k, v] of Object.entries(fileEnv)) {
// Keep `KEY=VALUE` plain (no quoting) — Dokploy parses the same way
Expand Down Expand Up @@ -165,6 +172,7 @@ export async function runDeployPipeline(params: PipelineParams): Promise<void> {
const infraMeta = await readKickoffInfraMetadata(projectRoot);
const databaseUrl: string | null = internalDatabaseUrlFrom(infraMeta);
const redisInternalUrl = internalRedisUrlFrom(infraMeta);
const s3Env = s3EnvFrom(infraMeta);
const reusedDokployProjectId: string | null = infraMeta?.dokployProjectId ?? null;
const reusedDokployEnvId: string | null = infraMeta?.dokployEnvironmentId ?? null;
if (!databaseUrl) {
Expand Down Expand Up @@ -249,6 +257,7 @@ export async function runDeployPipeline(params: PipelineParams): Promise<void> {
generatedCodePath,
databaseUrl,
redisInternalUrl,
s3Env,
);
console.log(
`[deploy] Compose env: ${envBlock.count} key(s) (${envBlock.sourcedFromBackendEnv ? "sourced from backend/.env" : "backend/.env not found — only DATABASE_URL/REDIS_URL"})`,
Expand Down
26 changes: 26 additions & 0 deletions src/lib/pipeline/generated-code-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,32 @@ export function upsertRedisUrlEnv(
return `${normalized}${serialized}\n`;
}

/**
* Upsert an arbitrary set of `KEY=value` pairs into an existing .env payload,
* preserving every other key. Replaces existing keys in place, appends new
* ones. Values are JSON-quoted so special characters survive. Used for the S3
* credential bundle (AWS_S3_BUCKET, AWS_S3_PREFIX, AWS_ACCESS_KEY_ID, …) which
* — unlike DATABASE_URL/REDIS_URL — is a multi-key set, not a single URL.
*/
export function upsertEnvVars(
envContent: string,
vars: Record<string, string>,
): string {
let result =
envContent.endsWith("\n") || envContent === "" ? envContent : `${envContent}\n`;
for (const [key, value] of Object.entries(vars)) {
if (!key) continue;
const serialized = `${key}=${JSON.stringify(value)}`;
const re = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=.*$`, "m");
if (re.test(result)) {
result = result.replace(re, serialized);
} else {
result = `${result}${serialized}\n`;
}
}
return result;
}

/** Upsert PORT (and align frontend VITE_API_BASE_URL) into an existing .env. */
export function upsertBackendPortEnv(
envContent: string,
Expand Down
54 changes: 43 additions & 11 deletions src/lib/pipeline/kickoff-infra/__tests__/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe("detectRequiredServicesByRegex", () => {
it("detects postgres by name", () => {
expect(
detectRequiredServicesByRegex("We use PostgreSQL for storage."),
).toEqual({ needsPostgres: true, needsRedis: false });
).toEqual({ needsPostgres: true, needsRedis: false, needsS3: false });
});
it("detects postgres via ORM mention", () => {
expect(
Expand All @@ -23,6 +23,7 @@ describe("detectRequiredServicesByRegex", () => {
expect(detectRequiredServicesByRegex("Sessions stored in Redis.")).toEqual({
needsPostgres: false,
needsRedis: true,
needsS3: false,
});
});
it("detects redis via BullMQ / cache hints", () => {
Expand All @@ -36,19 +37,44 @@ describe("detectRequiredServicesByRegex", () => {
true,
);
});
it("detects both", () => {
it("detects s3 by name", () => {
expect(
detectRequiredServicesByRegex(
"PostgreSQL for the main store; Redis for session cache.",
),
).toEqual({ needsPostgres: true, needsRedis: true });
detectRequiredServicesByRegex("Store uploads in an S3 bucket."),
).toEqual({ needsPostgres: false, needsRedis: false, needsS3: true });
});
it("detects s3 via upload / object-storage hints", () => {
expect(
detectRequiredServicesByRegex("Users can upload avatar images.")
.needsS3,
).toBe(true);
expect(
detectRequiredServicesByRegex("Files persisted to object storage.")
.needsS3,
).toBe(true);
expect(
detectRequiredServicesByRegex("Generate a presigned URL for downloads.")
.needsS3,
).toBe(true);
expect(
detectRequiredServicesByRegex("Document upload with MinIO.").needsS3,
).toBe(true);
});
it("neither detected for a static site", () => {
it("does NOT flag s3 for a text-only CRUD app", () => {
expect(
detectRequiredServicesByRegex("A todo list app with PostgreSQL.").needsS3,
).toBe(false);
});
it("detects all three", () => {
expect(
detectRequiredServicesByRegex(
"A simple static site rendered with Vite.",
"PostgreSQL for the main store; Redis for session cache; S3 for media uploads.",
),
).toEqual({ needsPostgres: false, needsRedis: false });
).toEqual({ needsPostgres: true, needsRedis: true, needsS3: true });
});
it("none detected for a static site", () => {
expect(
detectRequiredServicesByRegex("A simple static site rendered with Vite."),
).toEqual({ needsPostgres: false, needsRedis: false, needsS3: false });
});
});

Expand All @@ -63,8 +89,14 @@ describe("detectRequiredServices (orchestration)", () => {
});

it("uses regex path when INFRA_DETECT_REGEX_ONLY is set", async () => {
const r = await detectRequiredServices("PostgreSQL with Redis caching.");
const r = await detectRequiredServices(
"PostgreSQL with Redis caching and S3 image uploads.",
);
expect(r.source).toBe("regex");
expect(r.services).toEqual({ needsPostgres: true, needsRedis: true });
expect(r.services).toEqual({
needsPostgres: true,
needsRedis: true,
needsS3: true,
});
});
});
Loading