@msn-control/liftoff 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -8
- package/dist/args.d.ts +19 -0
- package/dist/args.js +216 -39
- package/dist/args.js.map +1 -1
- package/dist/catalogs.d.ts +7 -1
- package/dist/catalogs.js +55 -0
- package/dist/catalogs.js.map +1 -1
- package/dist/cli.js +12 -7
- package/dist/cli.js.map +1 -1
- package/dist/commands.js +172 -26
- package/dist/commands.js.map +1 -1
- package/dist/file-system.d.ts +3 -0
- package/dist/file-system.js +306 -24
- package/dist/file-system.js.map +1 -1
- package/dist/genai-templates.d.ts +10 -0
- package/dist/genai-templates.js +10 -0
- package/dist/genai-templates.js.map +1 -0
- package/dist/interactive.js +21 -6
- package/dist/interactive.js.map +1 -1
- package/dist/migrate-plan.d.ts +1 -1
- package/dist/migrate-plan.js +38 -10
- package/dist/migrate-plan.js.map +1 -1
- package/dist/planner.js +199 -11
- package/dist/planner.js.map +1 -1
- package/dist/reconcile.d.ts +1 -0
- package/dist/reconcile.js +58 -12
- package/dist/reconcile.js.map +1 -1
- package/dist/scan.d.ts +2 -1
- package/dist/scan.js +112 -3
- package/dist/scan.js.map +1 -1
- package/dist/standard-templates.d.ts +5 -0
- package/dist/standard-templates.js +942 -0
- package/dist/standard-templates.js.map +1 -0
- package/dist/template-types.d.ts +1 -0
- package/dist/template-types.js +2 -0
- package/dist/template-types.js.map +1 -0
- package/dist/templates.d.ts +18 -0
- package/dist/templates.js +1363 -142
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +24 -2
- package/package.json +6 -4
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
const stackBuilders = {
|
|
2
|
+
'python-fastapi': addPythonArtifacts,
|
|
3
|
+
'node-fastify': addNodeArtifacts,
|
|
4
|
+
'go-huma': addGoArtifacts
|
|
5
|
+
};
|
|
6
|
+
const sourceString = (value) => JSON.stringify(value);
|
|
7
|
+
const escapeHtml = (value) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
8
|
+
const localPostgresUrl = (host, database) => `postgresql:${'//'}postgres:postgres@${host}:5432/${database}`;
|
|
9
|
+
export function addStandardStackArtifacts(add, plan) {
|
|
10
|
+
stackBuilders[plan.apiStack.id](add, plan);
|
|
11
|
+
}
|
|
12
|
+
export function renderStandardDockerfile(plan) {
|
|
13
|
+
switch (plan.apiStack.id) {
|
|
14
|
+
case 'python-fastapi':
|
|
15
|
+
return `FROM python:3.12-slim
|
|
16
|
+
|
|
17
|
+
WORKDIR /app
|
|
18
|
+
|
|
19
|
+
ENV PYTHONDONTWRITEBYTECODE=1
|
|
20
|
+
ENV PYTHONUNBUFFERED=1
|
|
21
|
+
ENV PYTHONPATH=/app
|
|
22
|
+
|
|
23
|
+
COPY backend/pyproject.toml /app/backend/pyproject.toml
|
|
24
|
+
RUN pip install --no-cache-dir /app/backend
|
|
25
|
+
|
|
26
|
+
COPY backend /app/backend
|
|
27
|
+
COPY database /app/database
|
|
28
|
+
|
|
29
|
+
EXPOSE 8000
|
|
30
|
+
CMD ["uvicorn", "backend.apis.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
31
|
+
`;
|
|
32
|
+
case 'node-fastify':
|
|
33
|
+
return `FROM node:20-alpine AS build
|
|
34
|
+
|
|
35
|
+
WORKDIR /app/backend
|
|
36
|
+
COPY backend/package*.json ./
|
|
37
|
+
RUN npm install
|
|
38
|
+
COPY backend ./
|
|
39
|
+
RUN npm run build
|
|
40
|
+
|
|
41
|
+
FROM node:20-alpine AS runtime
|
|
42
|
+
WORKDIR /app/backend
|
|
43
|
+
ENV NODE_ENV=production
|
|
44
|
+
COPY backend/package*.json ./
|
|
45
|
+
RUN npm install --omit=dev
|
|
46
|
+
COPY --from=build /app/backend/dist ./dist
|
|
47
|
+
COPY database /app/database
|
|
48
|
+
|
|
49
|
+
EXPOSE 8000
|
|
50
|
+
CMD ["node", "dist/server.js"]
|
|
51
|
+
`;
|
|
52
|
+
case 'go-huma':
|
|
53
|
+
return `FROM golang:1.23-alpine AS build
|
|
54
|
+
|
|
55
|
+
WORKDIR /src/backend
|
|
56
|
+
COPY backend/go.mod backend/go.sum ./
|
|
57
|
+
RUN go mod download
|
|
58
|
+
COPY backend ./
|
|
59
|
+
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/api ./cmd/api
|
|
60
|
+
|
|
61
|
+
FROM alpine:3.20
|
|
62
|
+
RUN adduser -D -u 10001 liftoff
|
|
63
|
+
USER liftoff
|
|
64
|
+
WORKDIR /app
|
|
65
|
+
COPY --from=build /out/api /app/api
|
|
66
|
+
COPY database /app/database
|
|
67
|
+
|
|
68
|
+
EXPOSE 8000
|
|
69
|
+
CMD ["/app/api"]
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function renderStandardEnv(plan, environment = 'dev') {
|
|
74
|
+
const local = environment === 'dev';
|
|
75
|
+
const databaseUrl = localPostgresUrl('postgres', plan.safeProjectName.replace(/-/g, '_'));
|
|
76
|
+
return `APP_ENV=${environment}
|
|
77
|
+
APP_NAME=${plan.safeProjectName}
|
|
78
|
+
API_STACK=${plan.apiStack.id}
|
|
79
|
+
CLOUD_PROVIDER=${plan.provider.id}
|
|
80
|
+
AZURE_REGION=${plan.region.slug}
|
|
81
|
+
DATABASE_URL=${databaseUrl}
|
|
82
|
+
REDIS_URL=redis://redis:6379/0
|
|
83
|
+
MESSAGING_TRANSPORT=${local ? 'redis-streams' : 'azure-service-bus'}
|
|
84
|
+
BLOB_ENDPOINT=${local ? 'http://azurite:10000/devstoreaccount1' : ''}
|
|
85
|
+
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
function addPythonArtifacts(add, plan) {
|
|
89
|
+
add('backend-pyproject', 'backend', ['backend', 'pyproject.toml'], renderPythonPyproject(plan));
|
|
90
|
+
add('backend-package', 'backend', ['backend', '__init__.py'], '');
|
|
91
|
+
add('backend-api-package', 'backend', ['backend', 'apis', '__init__.py'], '');
|
|
92
|
+
add('backend-main', 'backend', ['backend', 'apis', 'main.py'], renderPythonMain(plan));
|
|
93
|
+
add('backend-health-routes', 'backend', ['backend', 'apis', 'routes', 'health.py'], renderPythonHealthRoutes());
|
|
94
|
+
add('backend-routes-package', 'backend', ['backend', 'apis', 'routes', '__init__.py'], '');
|
|
95
|
+
add('backend-auth-dependency', 'backend', ['backend', 'apis', 'dependencies', 'auth.py'], renderPythonAuthDependency());
|
|
96
|
+
add('backend-config-package', 'backend', ['backend', 'config', '__init__.py'], '');
|
|
97
|
+
add('backend-settings', 'backend', ['backend', 'config', 'settings.py'], renderPythonSettings(plan));
|
|
98
|
+
add('backend-observability', 'backend', ['backend', 'observability', 'logging.py'], renderPythonLogging());
|
|
99
|
+
add('backend-observability-package', 'backend', ['backend', 'observability', '__init__.py'], '');
|
|
100
|
+
add('backend-test-health', 'backend-test', ['backend', 'tests', 'test_health.py'], renderPythonHealthTest());
|
|
101
|
+
add('database-alembic-ini', 'database', ['database', 'alembic.ini'], renderAlembicIni());
|
|
102
|
+
add('database-alembic-env', 'database', ['database', 'migrations', 'env.py'], renderAlembicEnv());
|
|
103
|
+
add('database-initial-migration', 'database', ['database', 'migrations', 'versions', '0001_initial.py'], renderPythonMigration());
|
|
104
|
+
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderStandardSchema(plan));
|
|
105
|
+
}
|
|
106
|
+
function renderPythonPyproject(plan) {
|
|
107
|
+
return `[project]
|
|
108
|
+
name = "${plan.safeProjectName}-backend"
|
|
109
|
+
version = "0.1.0"
|
|
110
|
+
requires-python = ">=3.12"
|
|
111
|
+
dependencies = [
|
|
112
|
+
"fastapi>=0.111",
|
|
113
|
+
"uvicorn[standard]>=0.30",
|
|
114
|
+
"pydantic>=2.7",
|
|
115
|
+
"pydantic-settings>=2.3",
|
|
116
|
+
"scalar-fastapi>=1.0",
|
|
117
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
118
|
+
"asyncpg>=0.29",
|
|
119
|
+
"psycopg[binary]>=3.2",
|
|
120
|
+
"alembic>=1.13",
|
|
121
|
+
"redis>=5.0",
|
|
122
|
+
"azure-servicebus>=7.12",
|
|
123
|
+
"azure-storage-blob>=12.20",
|
|
124
|
+
"azure-communication-email>=1.0"
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
[project.optional-dependencies]
|
|
128
|
+
test = ["pytest>=8.2", "httpx>=0.27"]
|
|
129
|
+
|
|
130
|
+
[build-system]
|
|
131
|
+
requires = ["setuptools>=70"]
|
|
132
|
+
build-backend = "setuptools.build_meta"
|
|
133
|
+
|
|
134
|
+
[tool.setuptools]
|
|
135
|
+
packages = []
|
|
136
|
+
|
|
137
|
+
[tool.pytest.ini_options]
|
|
138
|
+
pythonpath = [".."]
|
|
139
|
+
testpaths = ["tests"]
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
function renderPythonMain(plan) {
|
|
143
|
+
return `from fastapi import FastAPI
|
|
144
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
from scalar_fastapi import get_scalar_api_reference
|
|
148
|
+
except ImportError: # pragma: no cover - dependency is present in generated runtime
|
|
149
|
+
get_scalar_api_reference = None
|
|
150
|
+
|
|
151
|
+
from backend.apis.routes import health
|
|
152
|
+
from backend.config.settings import get_settings
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
settings = get_settings()
|
|
156
|
+
app = FastAPI(title=settings.app_name, version="0.1.0")
|
|
157
|
+
app.add_middleware(
|
|
158
|
+
CORSMiddleware,
|
|
159
|
+
allow_origins=[
|
|
160
|
+
origin.strip()
|
|
161
|
+
for origin in settings.cors_allowed_origins.split(",")
|
|
162
|
+
if origin.strip()
|
|
163
|
+
],
|
|
164
|
+
allow_methods=["*"],
|
|
165
|
+
allow_headers=["*"],
|
|
166
|
+
)
|
|
167
|
+
app.include_router(health.router)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.get("/scalar", include_in_schema=False)
|
|
171
|
+
def scalar_reference():
|
|
172
|
+
if get_scalar_api_reference is None:
|
|
173
|
+
return {"message": "Install scalar-fastapi to enable the Scalar developer portal."}
|
|
174
|
+
return get_scalar_api_reference(openapi_url=app.openapi_url, title=f"{app.title} API")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.get("/api")
|
|
178
|
+
def api_root():
|
|
179
|
+
return {"name": ${sourceString(plan.projectName)}, "stack": "python-fastapi"}
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
function renderPythonHealthRoutes() {
|
|
183
|
+
return `from fastapi import APIRouter
|
|
184
|
+
|
|
185
|
+
router = APIRouter(tags=["operations"])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.get("/health")
|
|
189
|
+
def health():
|
|
190
|
+
return {"status": "ok"}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.get("/ready")
|
|
194
|
+
def ready():
|
|
195
|
+
return {"status": "ready"}
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
function renderPythonAuthDependency() {
|
|
199
|
+
return `from dataclasses import dataclass
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class CurrentUser:
|
|
204
|
+
subject: str = "local-developer"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def get_current_user() -> CurrentUser:
|
|
208
|
+
return CurrentUser()
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
function renderPythonSettings(plan) {
|
|
212
|
+
return `from functools import lru_cache
|
|
213
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Settings(BaseSettings):
|
|
217
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
218
|
+
|
|
219
|
+
app_name: str = ${sourceString(plan.projectName)}
|
|
220
|
+
app_env: str = "dev"
|
|
221
|
+
api_stack: str = "python-fastapi"
|
|
222
|
+
cloud_provider: str = "${plan.provider.id}"
|
|
223
|
+
azure_region: str = "${plan.region.slug}"
|
|
224
|
+
database_url: str
|
|
225
|
+
redis_url: str
|
|
226
|
+
messaging_transport: str = "redis-streams"
|
|
227
|
+
blob_endpoint: str | None = None
|
|
228
|
+
cors_allowed_origins: str = "http://localhost:5173"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@lru_cache
|
|
232
|
+
def get_settings() -> Settings:
|
|
233
|
+
return Settings()
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
function renderPythonLogging() {
|
|
237
|
+
return `import logging
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def configure_logging() -> None:
|
|
241
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
242
|
+
`;
|
|
243
|
+
}
|
|
244
|
+
function renderPythonHealthTest() {
|
|
245
|
+
return `from fastapi.testclient import TestClient
|
|
246
|
+
|
|
247
|
+
from backend.apis.main import app
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_health():
|
|
251
|
+
response = TestClient(app).get("/health")
|
|
252
|
+
assert response.status_code == 200
|
|
253
|
+
assert response.json()["status"] == "ok"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_ready():
|
|
257
|
+
response = TestClient(app).get("/ready")
|
|
258
|
+
assert response.status_code == 200
|
|
259
|
+
assert response.json()["status"] == "ready"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_cors_preflight_for_local_frontend():
|
|
263
|
+
response = TestClient(app).options(
|
|
264
|
+
"/api",
|
|
265
|
+
headers={
|
|
266
|
+
"Origin": "http://localhost:5173",
|
|
267
|
+
"Access-Control-Request-Method": "GET",
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
assert response.status_code == 200
|
|
271
|
+
assert response.headers["access-control-allow-origin"] == "http://localhost:5173"
|
|
272
|
+
`;
|
|
273
|
+
}
|
|
274
|
+
function renderAlembicIni() {
|
|
275
|
+
return `[alembic]
|
|
276
|
+
script_location = %(here)s/migrations
|
|
277
|
+
sqlalchemy.url =
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
function renderAlembicEnv() {
|
|
281
|
+
return `import os
|
|
282
|
+
|
|
283
|
+
from alembic import context
|
|
284
|
+
from sqlalchemy import create_engine
|
|
285
|
+
|
|
286
|
+
config = context.config
|
|
287
|
+
target_metadata = None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def run_migrations_online():
|
|
291
|
+
database_url = os.environ.get("DATABASE_URL")
|
|
292
|
+
if not database_url:
|
|
293
|
+
raise RuntimeError("DATABASE_URL is required to run migrations")
|
|
294
|
+
database_url = database_url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
|
|
295
|
+
database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1)
|
|
296
|
+
connectable = create_engine(database_url)
|
|
297
|
+
with connectable.connect() as connection:
|
|
298
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
299
|
+
with context.begin_transaction():
|
|
300
|
+
context.run_migrations()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
run_migrations_online()
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
306
|
+
function renderPythonMigration() {
|
|
307
|
+
return `from alembic import op
|
|
308
|
+
import sqlalchemy as sa
|
|
309
|
+
|
|
310
|
+
revision = "0001_initial"
|
|
311
|
+
down_revision = None
|
|
312
|
+
branch_labels = None
|
|
313
|
+
depends_on = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def upgrade():
|
|
317
|
+
op.create_table(
|
|
318
|
+
"app_records",
|
|
319
|
+
sa.Column("id", sa.Integer(), primary_key=True),
|
|
320
|
+
sa.Column("name", sa.String(length=255), nullable=False),
|
|
321
|
+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def downgrade():
|
|
326
|
+
op.drop_table("app_records")
|
|
327
|
+
`;
|
|
328
|
+
}
|
|
329
|
+
function addNodeArtifacts(add, plan) {
|
|
330
|
+
add('node-backend-package', 'backend', ['backend', 'package.json'], renderNodePackage(plan));
|
|
331
|
+
add('node-backend-tsconfig', 'backend', ['backend', 'tsconfig.json'], renderNodeTsconfig());
|
|
332
|
+
add('node-backend-drizzle-config', 'backend', ['backend', 'drizzle.config.ts'], renderNodeDrizzleConfig());
|
|
333
|
+
add('node-backend-config', 'backend', ['backend', 'src', 'config.ts'], renderNodeConfig(plan));
|
|
334
|
+
add('node-backend-app', 'backend', ['backend', 'src', 'app.ts'], renderNodeApp(plan));
|
|
335
|
+
add('node-backend-server', 'backend', ['backend', 'src', 'server.ts'], renderNodeServer());
|
|
336
|
+
add('node-backend-database', 'backend', ['backend', 'src', 'database.ts'], renderNodeDatabase());
|
|
337
|
+
add('node-backend-schema', 'backend', ['backend', 'src', 'db', 'schema.ts'], renderNodeSchema());
|
|
338
|
+
add('node-backend-test-health', 'backend-test', ['backend', 'test', 'health.test.ts'], renderNodeHealthTest());
|
|
339
|
+
add('database-node-migration', 'database', ['database', 'migrations', '0000_initial.sql'], renderNodeMigration());
|
|
340
|
+
add('database-node-migration-journal', 'database', ['database', 'migrations', 'meta', '_journal.json'], renderNodeMigrationJournal());
|
|
341
|
+
add('database-node-migration-snapshot', 'database', ['database', 'migrations', 'meta', '0000_snapshot.json'], renderNodeMigrationSnapshot());
|
|
342
|
+
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderStandardSchema(plan));
|
|
343
|
+
}
|
|
344
|
+
function renderNodePackage(plan) {
|
|
345
|
+
return JSON.stringify({
|
|
346
|
+
name: `${plan.safeProjectName}-backend`,
|
|
347
|
+
version: '0.1.0',
|
|
348
|
+
private: true,
|
|
349
|
+
type: 'module',
|
|
350
|
+
engines: { node: '>=20' },
|
|
351
|
+
scripts: {
|
|
352
|
+
dev: 'tsx watch src/server.ts',
|
|
353
|
+
build: 'tsc -p tsconfig.json',
|
|
354
|
+
start: 'node dist/server.js',
|
|
355
|
+
test: 'vitest run',
|
|
356
|
+
'db:generate': 'drizzle-kit generate',
|
|
357
|
+
'db:migrate': 'drizzle-kit migrate'
|
|
358
|
+
},
|
|
359
|
+
dependencies: {
|
|
360
|
+
'@fastify/cors': '^10.0.1',
|
|
361
|
+
'@fastify/swagger': '^9.4.0',
|
|
362
|
+
'drizzle-orm': '^0.44.0',
|
|
363
|
+
fastify: '^5.4.0',
|
|
364
|
+
pg: '^8.16.0'
|
|
365
|
+
},
|
|
366
|
+
devDependencies: {
|
|
367
|
+
'@types/node': '^20.14.10',
|
|
368
|
+
'@types/pg': '^8.15.0',
|
|
369
|
+
'drizzle-kit': '^0.31.0',
|
|
370
|
+
tsx: '^4.20.0',
|
|
371
|
+
typescript: '^5.5.4',
|
|
372
|
+
vitest: '^4.1.9'
|
|
373
|
+
}
|
|
374
|
+
}, null, 2);
|
|
375
|
+
}
|
|
376
|
+
function renderNodeTsconfig() {
|
|
377
|
+
return JSON.stringify({
|
|
378
|
+
compilerOptions: {
|
|
379
|
+
target: 'ES2022',
|
|
380
|
+
module: 'NodeNext',
|
|
381
|
+
moduleResolution: 'NodeNext',
|
|
382
|
+
rootDir: 'src',
|
|
383
|
+
outDir: 'dist',
|
|
384
|
+
strict: true,
|
|
385
|
+
esModuleInterop: true,
|
|
386
|
+
skipLibCheck: true
|
|
387
|
+
},
|
|
388
|
+
include: ['src/**/*.ts']
|
|
389
|
+
}, null, 2);
|
|
390
|
+
}
|
|
391
|
+
function renderNodeDrizzleConfig() {
|
|
392
|
+
const fallbackUrl = localPostgresUrl('localhost', 'postgres');
|
|
393
|
+
return `import { defineConfig } from 'drizzle-kit';
|
|
394
|
+
|
|
395
|
+
export default defineConfig({
|
|
396
|
+
dialect: 'postgresql',
|
|
397
|
+
schema: './src/db/schema.ts',
|
|
398
|
+
out: '../database/migrations',
|
|
399
|
+
dbCredentials: {
|
|
400
|
+
url: process.env.DATABASE_URL ?? ${sourceString(fallbackUrl)}
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
`;
|
|
404
|
+
}
|
|
405
|
+
function renderNodeConfig(plan) {
|
|
406
|
+
return `export interface AppConfig {
|
|
407
|
+
appName: string;
|
|
408
|
+
appEnv: string;
|
|
409
|
+
port: number;
|
|
410
|
+
cloudProvider: string;
|
|
411
|
+
azureRegion: string;
|
|
412
|
+
databaseUrl: string;
|
|
413
|
+
redisUrl: string;
|
|
414
|
+
messagingTransport: string;
|
|
415
|
+
blobEndpoint?: string;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|
419
|
+
const databaseUrl = env.DATABASE_URL;
|
|
420
|
+
const redisUrl = env.REDIS_URL;
|
|
421
|
+
if (!databaseUrl || !redisUrl) {
|
|
422
|
+
throw new Error('DATABASE_URL and REDIS_URL are required.');
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
appName: env.APP_NAME ?? ${sourceString(plan.projectName)},
|
|
426
|
+
appEnv: env.APP_ENV ?? 'dev',
|
|
427
|
+
port: Number.parseInt(env.PORT ?? '8000', 10),
|
|
428
|
+
cloudProvider: env.CLOUD_PROVIDER ?? '${plan.provider.id}',
|
|
429
|
+
azureRegion: env.AZURE_REGION ?? '${plan.region.slug}',
|
|
430
|
+
databaseUrl,
|
|
431
|
+
redisUrl,
|
|
432
|
+
messagingTransport: env.MESSAGING_TRANSPORT ?? 'redis-streams',
|
|
433
|
+
blobEndpoint: env.BLOB_ENDPOINT || undefined
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
`;
|
|
437
|
+
}
|
|
438
|
+
function renderNodeApp(plan) {
|
|
439
|
+
const scalarPage = `<!doctype html><html><head><title>${escapeHtml(plan.projectName)} API</title></head><body><script id="api-reference" data-url="/openapi.json"></script><script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script></body></html>`;
|
|
440
|
+
return `import cors from '@fastify/cors';
|
|
441
|
+
import swagger from '@fastify/swagger';
|
|
442
|
+
import Fastify from 'fastify';
|
|
443
|
+
|
|
444
|
+
const scalarPage = ${sourceString(scalarPage)};
|
|
445
|
+
|
|
446
|
+
export async function buildApp() {
|
|
447
|
+
const app = Fastify({ logger: true });
|
|
448
|
+
const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? 'http://localhost:5173')
|
|
449
|
+
.split(',')
|
|
450
|
+
.map((origin) => origin.trim())
|
|
451
|
+
.filter(Boolean);
|
|
452
|
+
await app.register(cors, { origin: allowedOrigins });
|
|
453
|
+
await app.register(swagger, {
|
|
454
|
+
openapi: {
|
|
455
|
+
info: { title: ${sourceString(`${plan.projectName} API`)}, version: '0.1.0' }
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const statusSchema = {
|
|
460
|
+
response: {
|
|
461
|
+
200: {
|
|
462
|
+
type: 'object',
|
|
463
|
+
required: ['status'],
|
|
464
|
+
properties: { status: { type: 'string' } }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} as const;
|
|
468
|
+
|
|
469
|
+
app.get('/health', { schema: statusSchema }, async () => ({ status: 'ok' }));
|
|
470
|
+
app.get('/ready', { schema: statusSchema }, async () => ({ status: 'ready' }));
|
|
471
|
+
app.get('/api', async () => ({ name: ${sourceString(plan.projectName)}, stack: 'node-fastify' }));
|
|
472
|
+
app.get('/openapi.json', async () => app.swagger());
|
|
473
|
+
app.get('/scalar', async (_request, reply) => reply.type('text/html').send(scalarPage));
|
|
474
|
+
return app;
|
|
475
|
+
}
|
|
476
|
+
`;
|
|
477
|
+
}
|
|
478
|
+
function renderNodeServer() {
|
|
479
|
+
return `import { buildApp } from './app.js';
|
|
480
|
+
import { loadConfig } from './config.js';
|
|
481
|
+
|
|
482
|
+
const config = loadConfig();
|
|
483
|
+
const app = await buildApp();
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
await app.listen({ host: '0.0.0.0', port: config.port });
|
|
487
|
+
} catch (error) {
|
|
488
|
+
app.log.error(error);
|
|
489
|
+
process.exitCode = 1;
|
|
490
|
+
}
|
|
491
|
+
`;
|
|
492
|
+
}
|
|
493
|
+
function renderNodeDatabase() {
|
|
494
|
+
return `import { drizzle } from 'drizzle-orm/node-postgres';
|
|
495
|
+
import { Pool } from 'pg';
|
|
496
|
+
import { loadConfig } from './config.js';
|
|
497
|
+
|
|
498
|
+
const pool = new Pool({ connectionString: loadConfig().databaseUrl });
|
|
499
|
+
export const database = drizzle(pool);
|
|
500
|
+
`;
|
|
501
|
+
}
|
|
502
|
+
function renderNodeSchema() {
|
|
503
|
+
return `import { integer, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core';
|
|
504
|
+
|
|
505
|
+
export const appRecords = pgTable('app_records', {
|
|
506
|
+
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
|
|
507
|
+
name: varchar('name', { length: 255 }).notNull(),
|
|
508
|
+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
|
509
|
+
});
|
|
510
|
+
`;
|
|
511
|
+
}
|
|
512
|
+
function renderNodeHealthTest() {
|
|
513
|
+
return `import { describe, expect, it } from 'vitest';
|
|
514
|
+
import { buildApp } from '../src/app.js';
|
|
515
|
+
|
|
516
|
+
describe('health endpoints', () => {
|
|
517
|
+
it('reports healthy and ready', async () => {
|
|
518
|
+
const app = await buildApp();
|
|
519
|
+
const health = await app.inject({ method: 'GET', url: '/health' });
|
|
520
|
+
const ready = await app.inject({ method: 'GET', url: '/ready' });
|
|
521
|
+
expect(health.json()).toEqual({ status: 'ok' });
|
|
522
|
+
expect(ready.json()).toEqual({ status: 'ready' });
|
|
523
|
+
const preflight = await app.inject({
|
|
524
|
+
method: 'OPTIONS',
|
|
525
|
+
url: '/api',
|
|
526
|
+
headers: {
|
|
527
|
+
origin: 'http://localhost:5173',
|
|
528
|
+
'access-control-request-method': 'GET'
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
expect(preflight.statusCode).toBe(204);
|
|
532
|
+
expect(preflight.headers['access-control-allow-origin']).toBe('http://localhost:5173');
|
|
533
|
+
await app.close();
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
`;
|
|
537
|
+
}
|
|
538
|
+
function renderNodeMigration() {
|
|
539
|
+
return `CREATE TABLE app_records (
|
|
540
|
+
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
541
|
+
name varchar(255) NOT NULL,
|
|
542
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
543
|
+
);
|
|
544
|
+
`;
|
|
545
|
+
}
|
|
546
|
+
function renderNodeMigrationJournal() {
|
|
547
|
+
return JSON.stringify({
|
|
548
|
+
version: '7',
|
|
549
|
+
dialect: 'postgresql',
|
|
550
|
+
entries: [
|
|
551
|
+
{
|
|
552
|
+
idx: 0,
|
|
553
|
+
version: '7',
|
|
554
|
+
when: 0,
|
|
555
|
+
tag: '0000_initial',
|
|
556
|
+
breakpoints: true
|
|
557
|
+
}
|
|
558
|
+
]
|
|
559
|
+
}, null, 2);
|
|
560
|
+
}
|
|
561
|
+
function renderNodeMigrationSnapshot() {
|
|
562
|
+
return JSON.stringify({
|
|
563
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
564
|
+
prevId: '00000000-0000-0000-0000-000000000000',
|
|
565
|
+
version: '7',
|
|
566
|
+
dialect: 'postgresql',
|
|
567
|
+
tables: {
|
|
568
|
+
'public.app_records': {
|
|
569
|
+
name: 'app_records',
|
|
570
|
+
schema: '',
|
|
571
|
+
columns: {
|
|
572
|
+
id: {
|
|
573
|
+
name: 'id',
|
|
574
|
+
type: 'integer',
|
|
575
|
+
primaryKey: true,
|
|
576
|
+
notNull: true,
|
|
577
|
+
identity: {
|
|
578
|
+
type: 'always',
|
|
579
|
+
name: 'app_records_id_seq',
|
|
580
|
+
schema: 'public',
|
|
581
|
+
increment: '1',
|
|
582
|
+
startWith: '1',
|
|
583
|
+
minValue: '1',
|
|
584
|
+
maxValue: '2147483647',
|
|
585
|
+
cache: '1',
|
|
586
|
+
cycle: false
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
name: {
|
|
590
|
+
name: 'name',
|
|
591
|
+
type: 'varchar(255)',
|
|
592
|
+
primaryKey: false,
|
|
593
|
+
notNull: true
|
|
594
|
+
},
|
|
595
|
+
created_at: {
|
|
596
|
+
name: 'created_at',
|
|
597
|
+
type: 'timestamp with time zone',
|
|
598
|
+
primaryKey: false,
|
|
599
|
+
notNull: true,
|
|
600
|
+
default: 'now()'
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
indexes: {},
|
|
604
|
+
foreignKeys: {},
|
|
605
|
+
compositePrimaryKeys: {},
|
|
606
|
+
uniqueConstraints: {},
|
|
607
|
+
policies: {},
|
|
608
|
+
checkConstraints: {},
|
|
609
|
+
isRLSEnabled: false
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
enums: {},
|
|
613
|
+
schemas: {},
|
|
614
|
+
sequences: {},
|
|
615
|
+
roles: {},
|
|
616
|
+
policies: {},
|
|
617
|
+
views: {},
|
|
618
|
+
_meta: {
|
|
619
|
+
columns: {},
|
|
620
|
+
schemas: {},
|
|
621
|
+
tables: {}
|
|
622
|
+
}
|
|
623
|
+
}, null, 2);
|
|
624
|
+
}
|
|
625
|
+
function addGoArtifacts(add, plan) {
|
|
626
|
+
add('go-backend-module', 'backend', ['backend', 'go.mod'], renderGoModule(plan));
|
|
627
|
+
add('go-backend-checksums', 'backend', ['backend', 'go.sum'], renderGoChecksums());
|
|
628
|
+
add('go-backend-makefile', 'backend', ['backend', 'Makefile'], renderGoMakefile());
|
|
629
|
+
add('go-backend-main', 'backend', ['backend', 'cmd', 'api', 'main.go'], renderGoMain(plan));
|
|
630
|
+
add('go-backend-api', 'backend', ['backend', 'internal', 'api', 'api.go'], renderGoApi(plan));
|
|
631
|
+
add('go-backend-config', 'backend', ['backend', 'internal', 'config', 'config.go'], renderGoConfig(plan));
|
|
632
|
+
add('go-backend-database', 'backend', ['backend', 'internal', 'database', 'database.go'], renderGoDatabase());
|
|
633
|
+
add('go-backend-test-health', 'backend-test', ['backend', 'internal', 'api', 'api_test.go'], renderGoHealthTest(plan));
|
|
634
|
+
add('database-go-migration', 'database', ['database', 'migrations', '0001_initial.sql'], renderGoMigration());
|
|
635
|
+
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderStandardSchema(plan));
|
|
636
|
+
}
|
|
637
|
+
function goModule(plan) {
|
|
638
|
+
return `example.com/${plan.packageName}/backend`;
|
|
639
|
+
}
|
|
640
|
+
function renderGoModule(plan) {
|
|
641
|
+
return `module ${goModule(plan)}
|
|
642
|
+
|
|
643
|
+
go 1.23
|
|
644
|
+
|
|
645
|
+
require (
|
|
646
|
+
github.com/danielgtaylor/huma/v2 v2.27.0
|
|
647
|
+
github.com/go-chi/chi/v5 v5.2.1
|
|
648
|
+
github.com/jackc/pgx/v5 v5.7.2
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
require (
|
|
652
|
+
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
653
|
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
654
|
+
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
655
|
+
golang.org/x/crypto v0.31.0 // indirect
|
|
656
|
+
golang.org/x/sync v0.10.0 // indirect
|
|
657
|
+
golang.org/x/text v0.21.0 // indirect
|
|
658
|
+
)
|
|
659
|
+
`;
|
|
660
|
+
}
|
|
661
|
+
function renderGoChecksums() {
|
|
662
|
+
return `github.com/danielgtaylor/huma/v2 v2.27.0 h1:yxgJ8GqYqKeXw/EnQ4ZNc2NBpmn49AlhxL2+ksSXjUI=
|
|
663
|
+
github.com/danielgtaylor/huma/v2 v2.27.0/go.mod h1:NbSFXRoOMh3BVmiLJQ9EbUpnPas7D9BeOxF/pZBAGa0=
|
|
664
|
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
665
|
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
666
|
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
667
|
+
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
|
668
|
+
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
|
669
|
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
670
|
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
671
|
+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
672
|
+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
673
|
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
674
|
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
675
|
+
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
|
676
|
+
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
|
677
|
+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
678
|
+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
679
|
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
680
|
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
681
|
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
682
|
+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
683
|
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
684
|
+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
685
|
+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
686
|
+
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
|
687
|
+
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
|
688
|
+
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
|
689
|
+
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
690
|
+
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
|
691
|
+
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
|
692
|
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
693
|
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
694
|
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
695
|
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
696
|
+
`;
|
|
697
|
+
}
|
|
698
|
+
function renderGoMakefile() {
|
|
699
|
+
return `GOOSE_VERSION := v3.24.1
|
|
700
|
+
|
|
701
|
+
.PHONY: test migrate
|
|
702
|
+
|
|
703
|
+
test:
|
|
704
|
+
go test ./...
|
|
705
|
+
|
|
706
|
+
migrate:
|
|
707
|
+
go run github.com/pressly/goose/v3/cmd/goose@$(GOOSE_VERSION) -dir ../database/migrations postgres "$(DATABASE_URL)" up
|
|
708
|
+
`;
|
|
709
|
+
}
|
|
710
|
+
function renderGoMain(plan) {
|
|
711
|
+
return `package main
|
|
712
|
+
|
|
713
|
+
import (
|
|
714
|
+
"log"
|
|
715
|
+
"net/http"
|
|
716
|
+
|
|
717
|
+
"${goModule(plan)}/internal/api"
|
|
718
|
+
"${goModule(plan)}/internal/config"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
func main() {
|
|
722
|
+
cfg, err := config.Load()
|
|
723
|
+
if err != nil {
|
|
724
|
+
log.Fatal(err)
|
|
725
|
+
}
|
|
726
|
+
log.Printf("%s listening on :%s", cfg.AppName, cfg.Port)
|
|
727
|
+
log.Fatal(http.ListenAndServe(":"+cfg.Port, api.New(cfg.AppName)))
|
|
728
|
+
}
|
|
729
|
+
`;
|
|
730
|
+
}
|
|
731
|
+
function renderGoApi(plan) {
|
|
732
|
+
return `package api
|
|
733
|
+
|
|
734
|
+
import (
|
|
735
|
+
"context"
|
|
736
|
+
"fmt"
|
|
737
|
+
"net/http"
|
|
738
|
+
"os"
|
|
739
|
+
"strings"
|
|
740
|
+
|
|
741
|
+
"github.com/danielgtaylor/huma/v2"
|
|
742
|
+
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
|
743
|
+
"github.com/go-chi/chi/v5"
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
type statusOutput struct {
|
|
747
|
+
Body struct {
|
|
748
|
+
Status string \`json:"status"\`
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
func New(name string) http.Handler {
|
|
753
|
+
router := chi.NewRouter()
|
|
754
|
+
router.Use(corsMiddleware(configuredOrigins()))
|
|
755
|
+
config := huma.DefaultConfig(name+" API", "0.1.0")
|
|
756
|
+
config.OpenAPIPath = "/openapi"
|
|
757
|
+
config.DocsPath = ""
|
|
758
|
+
api := humachi.New(router, config)
|
|
759
|
+
|
|
760
|
+
huma.Get(api, "/health", func(context.Context, *struct{}) (*statusOutput, error) {
|
|
761
|
+
output := &statusOutput{}
|
|
762
|
+
output.Body.Status = "ok"
|
|
763
|
+
return output, nil
|
|
764
|
+
})
|
|
765
|
+
huma.Get(api, "/ready", func(context.Context, *struct{}) (*statusOutput, error) {
|
|
766
|
+
output := &statusOutput{}
|
|
767
|
+
output.Body.Status = "ready"
|
|
768
|
+
return output, nil
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
router.Get("/api", func(response http.ResponseWriter, _ *http.Request) {
|
|
772
|
+
response.Header().Set("Content-Type", "application/json")
|
|
773
|
+
fmt.Fprint(response, ${sourceString(JSON.stringify({ name: plan.projectName, stack: 'go-huma' }))})
|
|
774
|
+
})
|
|
775
|
+
router.Get("/scalar", scalarReference)
|
|
776
|
+
return router
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
func configuredOrigins() map[string]struct{} {
|
|
780
|
+
value := os.Getenv("CORS_ALLOWED_ORIGINS")
|
|
781
|
+
if value == "" {
|
|
782
|
+
value = "http://localhost:5173"
|
|
783
|
+
}
|
|
784
|
+
origins := make(map[string]struct{})
|
|
785
|
+
for _, origin := range strings.Split(value, ",") {
|
|
786
|
+
if origin = strings.TrimSpace(origin); origin != "" {
|
|
787
|
+
origins[origin] = struct{}{}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return origins
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
func corsMiddleware(allowedOrigins map[string]struct{}) func(http.Handler) http.Handler {
|
|
794
|
+
return func(next http.Handler) http.Handler {
|
|
795
|
+
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
796
|
+
origin := request.Header.Get("Origin")
|
|
797
|
+
_, allowed := allowedOrigins[origin]
|
|
798
|
+
if allowed {
|
|
799
|
+
response.Header().Set("Access-Control-Allow-Origin", origin)
|
|
800
|
+
response.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
801
|
+
response.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
802
|
+
response.Header().Add("Vary", "Origin")
|
|
803
|
+
}
|
|
804
|
+
if request.Method == http.MethodOptions {
|
|
805
|
+
if origin != "" && !allowed {
|
|
806
|
+
http.Error(response, "origin is not allowed", http.StatusForbidden)
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
response.WriteHeader(http.StatusNoContent)
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
next.ServeHTTP(response, request)
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
func scalarReference(response http.ResponseWriter, _ *http.Request) {
|
|
818
|
+
response.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
819
|
+
fmt.Fprint(response, \`<!doctype html><html><head><title>API Reference</title></head><body><script id="api-reference" data-url="/openapi.json"></script><script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script></body></html>\`)
|
|
820
|
+
}
|
|
821
|
+
`;
|
|
822
|
+
}
|
|
823
|
+
function renderGoConfig(plan) {
|
|
824
|
+
return `package config
|
|
825
|
+
|
|
826
|
+
import (
|
|
827
|
+
"fmt"
|
|
828
|
+
"os"
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
type Config struct {
|
|
832
|
+
AppName string
|
|
833
|
+
AppEnv string
|
|
834
|
+
Port string
|
|
835
|
+
CloudProvider string
|
|
836
|
+
AzureRegion string
|
|
837
|
+
DatabaseURL string
|
|
838
|
+
RedisURL string
|
|
839
|
+
MessagingTransport string
|
|
840
|
+
BlobEndpoint string
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
func Load() (Config, error) {
|
|
844
|
+
databaseURL := os.Getenv("DATABASE_URL")
|
|
845
|
+
redisURL := os.Getenv("REDIS_URL")
|
|
846
|
+
if databaseURL == "" || redisURL == "" {
|
|
847
|
+
return Config{}, fmt.Errorf("DATABASE_URL and REDIS_URL are required")
|
|
848
|
+
}
|
|
849
|
+
return Config{
|
|
850
|
+
AppName: value("APP_NAME", ${sourceString(plan.projectName)}),
|
|
851
|
+
AppEnv: value("APP_ENV", "dev"),
|
|
852
|
+
Port: value("PORT", "8000"),
|
|
853
|
+
CloudProvider: value("CLOUD_PROVIDER", "${plan.provider.id}"),
|
|
854
|
+
AzureRegion: value("AZURE_REGION", "${plan.region.slug}"),
|
|
855
|
+
DatabaseURL: databaseURL,
|
|
856
|
+
RedisURL: redisURL,
|
|
857
|
+
MessagingTransport: value("MESSAGING_TRANSPORT", "redis-streams"),
|
|
858
|
+
BlobEndpoint: os.Getenv("BLOB_ENDPOINT"),
|
|
859
|
+
}, nil
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
func value(name, fallback string) string {
|
|
863
|
+
if current := os.Getenv(name); current != "" {
|
|
864
|
+
return current
|
|
865
|
+
}
|
|
866
|
+
return fallback
|
|
867
|
+
}
|
|
868
|
+
`;
|
|
869
|
+
}
|
|
870
|
+
function renderGoDatabase() {
|
|
871
|
+
return `package database
|
|
872
|
+
|
|
873
|
+
import (
|
|
874
|
+
"context"
|
|
875
|
+
|
|
876
|
+
"github.com/jackc/pgx/v5/pgxpool"
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
|
880
|
+
return pgxpool.New(ctx, databaseURL)
|
|
881
|
+
}
|
|
882
|
+
`;
|
|
883
|
+
}
|
|
884
|
+
function renderGoHealthTest(plan) {
|
|
885
|
+
return `package api
|
|
886
|
+
|
|
887
|
+
import (
|
|
888
|
+
"net/http"
|
|
889
|
+
"net/http/httptest"
|
|
890
|
+
"testing"
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
func TestHealthAndReady(t *testing.T) {
|
|
894
|
+
handler := New(${sourceString(plan.projectName)})
|
|
895
|
+
for _, path := range []string{"/health", "/ready"} {
|
|
896
|
+
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
897
|
+
response := httptest.NewRecorder()
|
|
898
|
+
handler.ServeHTTP(response, request)
|
|
899
|
+
if response.Code != http.StatusOK {
|
|
900
|
+
t.Fatalf("%s returned %d", path, response.Code)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
func TestCorsPreflightForLocalFrontend(t *testing.T) {
|
|
906
|
+
handler := New(${sourceString(plan.projectName)})
|
|
907
|
+
request := httptest.NewRequest(http.MethodOptions, "/api", nil)
|
|
908
|
+
request.Header.Set("Origin", "http://localhost:5173")
|
|
909
|
+
request.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
|
910
|
+
response := httptest.NewRecorder()
|
|
911
|
+
handler.ServeHTTP(response, request)
|
|
912
|
+
if response.Code != http.StatusNoContent {
|
|
913
|
+
t.Fatalf("preflight returned %d", response.Code)
|
|
914
|
+
}
|
|
915
|
+
if origin := response.Header().Get("Access-Control-Allow-Origin"); origin != "http://localhost:5173" {
|
|
916
|
+
t.Fatalf("unexpected allow origin %q", origin)
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
`;
|
|
920
|
+
}
|
|
921
|
+
function renderGoMigration() {
|
|
922
|
+
return `-- +goose Up
|
|
923
|
+
CREATE TABLE app_records (
|
|
924
|
+
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
925
|
+
name varchar(255) NOT NULL,
|
|
926
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
-- +goose Down
|
|
930
|
+
DROP TABLE app_records;
|
|
931
|
+
`;
|
|
932
|
+
}
|
|
933
|
+
function renderStandardSchema(plan) {
|
|
934
|
+
return `-- ${plan.safeProjectName} standard application schema
|
|
935
|
+
CREATE TABLE IF NOT EXISTS app_records (
|
|
936
|
+
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
937
|
+
name varchar(255) NOT NULL,
|
|
938
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
939
|
+
);
|
|
940
|
+
`;
|
|
941
|
+
}
|
|
942
|
+
//# sourceMappingURL=standard-templates.js.map
|