@msn-control/liftoff 0.2.1 → 0.3.0
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 +131 -7
- package/dist/args.js +2 -2
- 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/commands.js +129 -18
- package/dist/commands.js.map +1 -1
- package/dist/file-system.js +34 -0
- 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 +86 -8
- package/dist/planner.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 +797 -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.js +445 -75
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +24 -2
- package/package.json +5 -2
|
@@ -0,0 +1,797 @@
|
|
|
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 ./
|
|
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
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
function addPythonArtifacts(add, plan) {
|
|
88
|
+
add('backend-pyproject', 'backend', ['backend', 'pyproject.toml'], renderPythonPyproject(plan));
|
|
89
|
+
add('backend-package', 'backend', ['backend', '__init__.py'], '');
|
|
90
|
+
add('backend-api-package', 'backend', ['backend', 'apis', '__init__.py'], '');
|
|
91
|
+
add('backend-main', 'backend', ['backend', 'apis', 'main.py'], renderPythonMain(plan));
|
|
92
|
+
add('backend-health-routes', 'backend', ['backend', 'apis', 'routes', 'health.py'], renderPythonHealthRoutes());
|
|
93
|
+
add('backend-routes-package', 'backend', ['backend', 'apis', 'routes', '__init__.py'], '');
|
|
94
|
+
add('backend-auth-dependency', 'backend', ['backend', 'apis', 'dependencies', 'auth.py'], renderPythonAuthDependency());
|
|
95
|
+
add('backend-config-package', 'backend', ['backend', 'config', '__init__.py'], '');
|
|
96
|
+
add('backend-settings', 'backend', ['backend', 'config', 'settings.py'], renderPythonSettings(plan));
|
|
97
|
+
add('backend-observability', 'backend', ['backend', 'observability', 'logging.py'], renderPythonLogging());
|
|
98
|
+
add('backend-observability-package', 'backend', ['backend', 'observability', '__init__.py'], '');
|
|
99
|
+
add('backend-test-health', 'backend-test', ['backend', 'tests', 'test_health.py'], renderPythonHealthTest());
|
|
100
|
+
add('database-alembic-ini', 'database', ['database', 'alembic.ini'], renderAlembicIni());
|
|
101
|
+
add('database-alembic-env', 'database', ['database', 'migrations', 'env.py'], renderAlembicEnv());
|
|
102
|
+
add('database-initial-migration', 'database', ['database', 'migrations', 'versions', '0001_initial.py'], renderPythonMigration());
|
|
103
|
+
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderStandardSchema(plan));
|
|
104
|
+
}
|
|
105
|
+
function renderPythonPyproject(plan) {
|
|
106
|
+
return `[project]
|
|
107
|
+
name = "${plan.safeProjectName}-backend"
|
|
108
|
+
version = "0.1.0"
|
|
109
|
+
requires-python = ">=3.12"
|
|
110
|
+
dependencies = [
|
|
111
|
+
"fastapi>=0.111",
|
|
112
|
+
"uvicorn[standard]>=0.30",
|
|
113
|
+
"pydantic>=2.7",
|
|
114
|
+
"pydantic-settings>=2.3",
|
|
115
|
+
"scalar-fastapi>=1.0",
|
|
116
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
117
|
+
"asyncpg>=0.29",
|
|
118
|
+
"psycopg[binary]>=3.2",
|
|
119
|
+
"alembic>=1.13",
|
|
120
|
+
"redis>=5.0",
|
|
121
|
+
"azure-servicebus>=7.12",
|
|
122
|
+
"azure-storage-blob>=12.20",
|
|
123
|
+
"azure-communication-email>=1.0"
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
[project.optional-dependencies]
|
|
127
|
+
test = ["pytest>=8.2", "httpx>=0.27"]
|
|
128
|
+
|
|
129
|
+
[build-system]
|
|
130
|
+
requires = ["setuptools>=70"]
|
|
131
|
+
build-backend = "setuptools.build_meta"
|
|
132
|
+
|
|
133
|
+
[tool.setuptools]
|
|
134
|
+
packages = []
|
|
135
|
+
|
|
136
|
+
[tool.pytest.ini_options]
|
|
137
|
+
pythonpath = [".."]
|
|
138
|
+
testpaths = ["tests"]
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
141
|
+
function renderPythonMain(plan) {
|
|
142
|
+
return `from fastapi import FastAPI
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
from scalar_fastapi import get_scalar_api_reference
|
|
146
|
+
except ImportError: # pragma: no cover - dependency is present in generated runtime
|
|
147
|
+
get_scalar_api_reference = None
|
|
148
|
+
|
|
149
|
+
from backend.apis.routes import health
|
|
150
|
+
from backend.config.settings import get_settings
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
settings = get_settings()
|
|
154
|
+
app = FastAPI(title=settings.app_name, version="0.1.0")
|
|
155
|
+
app.include_router(health.router)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.get("/scalar", include_in_schema=False)
|
|
159
|
+
def scalar_reference():
|
|
160
|
+
if get_scalar_api_reference is None:
|
|
161
|
+
return {"message": "Install scalar-fastapi to enable the Scalar developer portal."}
|
|
162
|
+
return get_scalar_api_reference(openapi_url=app.openapi_url, title=f"{app.title} API")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.get("/api")
|
|
166
|
+
def api_root():
|
|
167
|
+
return {"name": ${sourceString(plan.projectName)}, "stack": "python-fastapi"}
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
function renderPythonHealthRoutes() {
|
|
171
|
+
return `from fastapi import APIRouter
|
|
172
|
+
|
|
173
|
+
router = APIRouter(tags=["operations"])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@router.get("/health")
|
|
177
|
+
def health():
|
|
178
|
+
return {"status": "ok"}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.get("/ready")
|
|
182
|
+
def ready():
|
|
183
|
+
return {"status": "ready"}
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
function renderPythonAuthDependency() {
|
|
187
|
+
return `from dataclasses import dataclass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class CurrentUser:
|
|
192
|
+
subject: str = "local-developer"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def get_current_user() -> CurrentUser:
|
|
196
|
+
return CurrentUser()
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
function renderPythonSettings(plan) {
|
|
200
|
+
return `from functools import lru_cache
|
|
201
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Settings(BaseSettings):
|
|
205
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
206
|
+
|
|
207
|
+
app_name: str = ${sourceString(plan.projectName)}
|
|
208
|
+
app_env: str = "dev"
|
|
209
|
+
api_stack: str = "python-fastapi"
|
|
210
|
+
cloud_provider: str = "${plan.provider.id}"
|
|
211
|
+
azure_region: str = "${plan.region.slug}"
|
|
212
|
+
database_url: str
|
|
213
|
+
redis_url: str
|
|
214
|
+
messaging_transport: str = "redis-streams"
|
|
215
|
+
blob_endpoint: str | None = None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@lru_cache
|
|
219
|
+
def get_settings() -> Settings:
|
|
220
|
+
return Settings()
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
function renderPythonLogging() {
|
|
224
|
+
return `import logging
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def configure_logging() -> None:
|
|
228
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
229
|
+
`;
|
|
230
|
+
}
|
|
231
|
+
function renderPythonHealthTest() {
|
|
232
|
+
return `from fastapi.testclient import TestClient
|
|
233
|
+
|
|
234
|
+
from backend.apis.main import app
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_health():
|
|
238
|
+
response = TestClient(app).get("/health")
|
|
239
|
+
assert response.status_code == 200
|
|
240
|
+
assert response.json()["status"] == "ok"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_ready():
|
|
244
|
+
response = TestClient(app).get("/ready")
|
|
245
|
+
assert response.status_code == 200
|
|
246
|
+
assert response.json()["status"] == "ready"
|
|
247
|
+
`;
|
|
248
|
+
}
|
|
249
|
+
function renderAlembicIni() {
|
|
250
|
+
return `[alembic]
|
|
251
|
+
script_location = %(here)s/migrations
|
|
252
|
+
sqlalchemy.url =
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
function renderAlembicEnv() {
|
|
256
|
+
return `import os
|
|
257
|
+
|
|
258
|
+
from alembic import context
|
|
259
|
+
from sqlalchemy import create_engine
|
|
260
|
+
|
|
261
|
+
config = context.config
|
|
262
|
+
target_metadata = None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def run_migrations_online():
|
|
266
|
+
database_url = os.environ.get("DATABASE_URL")
|
|
267
|
+
if not database_url:
|
|
268
|
+
raise RuntimeError("DATABASE_URL is required to run migrations")
|
|
269
|
+
database_url = database_url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
|
|
270
|
+
database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1)
|
|
271
|
+
connectable = create_engine(database_url)
|
|
272
|
+
with connectable.connect() as connection:
|
|
273
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
274
|
+
with context.begin_transaction():
|
|
275
|
+
context.run_migrations()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
run_migrations_online()
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
function renderPythonMigration() {
|
|
282
|
+
return `from alembic import op
|
|
283
|
+
import sqlalchemy as sa
|
|
284
|
+
|
|
285
|
+
revision = "0001_initial"
|
|
286
|
+
down_revision = None
|
|
287
|
+
branch_labels = None
|
|
288
|
+
depends_on = None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def upgrade():
|
|
292
|
+
op.create_table(
|
|
293
|
+
"app_records",
|
|
294
|
+
sa.Column("id", sa.Integer(), primary_key=True),
|
|
295
|
+
sa.Column("name", sa.String(length=255), nullable=False),
|
|
296
|
+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def downgrade():
|
|
301
|
+
op.drop_table("app_records")
|
|
302
|
+
`;
|
|
303
|
+
}
|
|
304
|
+
function addNodeArtifacts(add, plan) {
|
|
305
|
+
add('node-backend-package', 'backend', ['backend', 'package.json'], renderNodePackage(plan));
|
|
306
|
+
add('node-backend-tsconfig', 'backend', ['backend', 'tsconfig.json'], renderNodeTsconfig());
|
|
307
|
+
add('node-backend-drizzle-config', 'backend', ['backend', 'drizzle.config.ts'], renderNodeDrizzleConfig());
|
|
308
|
+
add('node-backend-config', 'backend', ['backend', 'src', 'config.ts'], renderNodeConfig(plan));
|
|
309
|
+
add('node-backend-app', 'backend', ['backend', 'src', 'app.ts'], renderNodeApp(plan));
|
|
310
|
+
add('node-backend-server', 'backend', ['backend', 'src', 'server.ts'], renderNodeServer());
|
|
311
|
+
add('node-backend-database', 'backend', ['backend', 'src', 'database.ts'], renderNodeDatabase());
|
|
312
|
+
add('node-backend-schema', 'backend', ['backend', 'src', 'db', 'schema.ts'], renderNodeSchema());
|
|
313
|
+
add('node-backend-test-health', 'backend-test', ['backend', 'test', 'health.test.ts'], renderNodeHealthTest());
|
|
314
|
+
add('database-node-migration', 'database', ['database', 'migrations', '0000_initial.sql'], renderNodeMigration());
|
|
315
|
+
add('database-node-migration-journal', 'database', ['database', 'migrations', 'meta', '_journal.json'], renderNodeMigrationJournal());
|
|
316
|
+
add('database-node-migration-snapshot', 'database', ['database', 'migrations', 'meta', '0000_snapshot.json'], renderNodeMigrationSnapshot());
|
|
317
|
+
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderStandardSchema(plan));
|
|
318
|
+
}
|
|
319
|
+
function renderNodePackage(plan) {
|
|
320
|
+
return JSON.stringify({
|
|
321
|
+
name: `${plan.safeProjectName}-backend`,
|
|
322
|
+
version: '0.1.0',
|
|
323
|
+
private: true,
|
|
324
|
+
type: 'module',
|
|
325
|
+
engines: { node: '>=20' },
|
|
326
|
+
scripts: {
|
|
327
|
+
dev: 'tsx watch src/server.ts',
|
|
328
|
+
build: 'tsc -p tsconfig.json',
|
|
329
|
+
start: 'node dist/server.js',
|
|
330
|
+
test: 'vitest run',
|
|
331
|
+
'db:generate': 'drizzle-kit generate',
|
|
332
|
+
'db:migrate': 'drizzle-kit migrate'
|
|
333
|
+
},
|
|
334
|
+
dependencies: {
|
|
335
|
+
'@fastify/swagger': '^9.4.0',
|
|
336
|
+
'drizzle-orm': '^0.44.0',
|
|
337
|
+
fastify: '^5.4.0',
|
|
338
|
+
pg: '^8.16.0'
|
|
339
|
+
},
|
|
340
|
+
devDependencies: {
|
|
341
|
+
'@types/node': '^20.14.10',
|
|
342
|
+
'@types/pg': '^8.15.0',
|
|
343
|
+
'drizzle-kit': '^0.31.0',
|
|
344
|
+
tsx: '^4.20.0',
|
|
345
|
+
typescript: '^5.5.4',
|
|
346
|
+
vitest: '^4.1.9'
|
|
347
|
+
}
|
|
348
|
+
}, null, 2);
|
|
349
|
+
}
|
|
350
|
+
function renderNodeTsconfig() {
|
|
351
|
+
return JSON.stringify({
|
|
352
|
+
compilerOptions: {
|
|
353
|
+
target: 'ES2022',
|
|
354
|
+
module: 'NodeNext',
|
|
355
|
+
moduleResolution: 'NodeNext',
|
|
356
|
+
rootDir: 'src',
|
|
357
|
+
outDir: 'dist',
|
|
358
|
+
strict: true,
|
|
359
|
+
esModuleInterop: true,
|
|
360
|
+
skipLibCheck: true
|
|
361
|
+
},
|
|
362
|
+
include: ['src/**/*.ts']
|
|
363
|
+
}, null, 2);
|
|
364
|
+
}
|
|
365
|
+
function renderNodeDrizzleConfig() {
|
|
366
|
+
const fallbackUrl = localPostgresUrl('localhost', 'postgres');
|
|
367
|
+
return `import { defineConfig } from 'drizzle-kit';
|
|
368
|
+
|
|
369
|
+
export default defineConfig({
|
|
370
|
+
dialect: 'postgresql',
|
|
371
|
+
schema: './src/db/schema.ts',
|
|
372
|
+
out: '../database/migrations',
|
|
373
|
+
dbCredentials: {
|
|
374
|
+
url: process.env.DATABASE_URL ?? ${sourceString(fallbackUrl)}
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
function renderNodeConfig(plan) {
|
|
380
|
+
return `export interface AppConfig {
|
|
381
|
+
appName: string;
|
|
382
|
+
appEnv: string;
|
|
383
|
+
port: number;
|
|
384
|
+
cloudProvider: string;
|
|
385
|
+
azureRegion: string;
|
|
386
|
+
databaseUrl: string;
|
|
387
|
+
redisUrl: string;
|
|
388
|
+
messagingTransport: string;
|
|
389
|
+
blobEndpoint?: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|
393
|
+
const databaseUrl = env.DATABASE_URL;
|
|
394
|
+
const redisUrl = env.REDIS_URL;
|
|
395
|
+
if (!databaseUrl || !redisUrl) {
|
|
396
|
+
throw new Error('DATABASE_URL and REDIS_URL are required.');
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
appName: env.APP_NAME ?? ${sourceString(plan.projectName)},
|
|
400
|
+
appEnv: env.APP_ENV ?? 'dev',
|
|
401
|
+
port: Number.parseInt(env.PORT ?? '8000', 10),
|
|
402
|
+
cloudProvider: env.CLOUD_PROVIDER ?? '${plan.provider.id}',
|
|
403
|
+
azureRegion: env.AZURE_REGION ?? '${plan.region.slug}',
|
|
404
|
+
databaseUrl,
|
|
405
|
+
redisUrl,
|
|
406
|
+
messagingTransport: env.MESSAGING_TRANSPORT ?? 'redis-streams',
|
|
407
|
+
blobEndpoint: env.BLOB_ENDPOINT || undefined
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
`;
|
|
411
|
+
}
|
|
412
|
+
function renderNodeApp(plan) {
|
|
413
|
+
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>`;
|
|
414
|
+
return `import swagger from '@fastify/swagger';
|
|
415
|
+
import Fastify from 'fastify';
|
|
416
|
+
|
|
417
|
+
const scalarPage = ${sourceString(scalarPage)};
|
|
418
|
+
|
|
419
|
+
export async function buildApp() {
|
|
420
|
+
const app = Fastify({ logger: true });
|
|
421
|
+
await app.register(swagger, {
|
|
422
|
+
openapi: {
|
|
423
|
+
info: { title: ${sourceString(`${plan.projectName} API`)}, version: '0.1.0' }
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const statusSchema = {
|
|
428
|
+
response: {
|
|
429
|
+
200: {
|
|
430
|
+
type: 'object',
|
|
431
|
+
required: ['status'],
|
|
432
|
+
properties: { status: { type: 'string' } }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} as const;
|
|
436
|
+
|
|
437
|
+
app.get('/health', { schema: statusSchema }, async () => ({ status: 'ok' }));
|
|
438
|
+
app.get('/ready', { schema: statusSchema }, async () => ({ status: 'ready' }));
|
|
439
|
+
app.get('/api', async () => ({ name: ${sourceString(plan.projectName)}, stack: 'node-fastify' }));
|
|
440
|
+
app.get('/openapi.json', async () => app.swagger());
|
|
441
|
+
app.get('/scalar', async (_request, reply) => reply.type('text/html').send(scalarPage));
|
|
442
|
+
return app;
|
|
443
|
+
}
|
|
444
|
+
`;
|
|
445
|
+
}
|
|
446
|
+
function renderNodeServer() {
|
|
447
|
+
return `import { buildApp } from './app.js';
|
|
448
|
+
import { loadConfig } from './config.js';
|
|
449
|
+
|
|
450
|
+
const config = loadConfig();
|
|
451
|
+
const app = await buildApp();
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
await app.listen({ host: '0.0.0.0', port: config.port });
|
|
455
|
+
} catch (error) {
|
|
456
|
+
app.log.error(error);
|
|
457
|
+
process.exitCode = 1;
|
|
458
|
+
}
|
|
459
|
+
`;
|
|
460
|
+
}
|
|
461
|
+
function renderNodeDatabase() {
|
|
462
|
+
return `import { drizzle } from 'drizzle-orm/node-postgres';
|
|
463
|
+
import { Pool } from 'pg';
|
|
464
|
+
import { loadConfig } from './config.js';
|
|
465
|
+
|
|
466
|
+
const pool = new Pool({ connectionString: loadConfig().databaseUrl });
|
|
467
|
+
export const database = drizzle(pool);
|
|
468
|
+
`;
|
|
469
|
+
}
|
|
470
|
+
function renderNodeSchema() {
|
|
471
|
+
return `import { integer, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core';
|
|
472
|
+
|
|
473
|
+
export const appRecords = pgTable('app_records', {
|
|
474
|
+
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
|
|
475
|
+
name: varchar('name', { length: 255 }).notNull(),
|
|
476
|
+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
|
477
|
+
});
|
|
478
|
+
`;
|
|
479
|
+
}
|
|
480
|
+
function renderNodeHealthTest() {
|
|
481
|
+
return `import { describe, expect, it } from 'vitest';
|
|
482
|
+
import { buildApp } from '../src/app.js';
|
|
483
|
+
|
|
484
|
+
describe('health endpoints', () => {
|
|
485
|
+
it('reports healthy and ready', async () => {
|
|
486
|
+
const app = await buildApp();
|
|
487
|
+
const health = await app.inject({ method: 'GET', url: '/health' });
|
|
488
|
+
const ready = await app.inject({ method: 'GET', url: '/ready' });
|
|
489
|
+
expect(health.json()).toEqual({ status: 'ok' });
|
|
490
|
+
expect(ready.json()).toEqual({ status: 'ready' });
|
|
491
|
+
await app.close();
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
`;
|
|
495
|
+
}
|
|
496
|
+
function renderNodeMigration() {
|
|
497
|
+
return `CREATE TABLE app_records (
|
|
498
|
+
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
499
|
+
name varchar(255) NOT NULL,
|
|
500
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
501
|
+
);
|
|
502
|
+
`;
|
|
503
|
+
}
|
|
504
|
+
function renderNodeMigrationJournal() {
|
|
505
|
+
return JSON.stringify({
|
|
506
|
+
version: '7',
|
|
507
|
+
dialect: 'postgresql',
|
|
508
|
+
entries: [
|
|
509
|
+
{
|
|
510
|
+
idx: 0,
|
|
511
|
+
version: '7',
|
|
512
|
+
when: 0,
|
|
513
|
+
tag: '0000_initial',
|
|
514
|
+
breakpoints: true
|
|
515
|
+
}
|
|
516
|
+
]
|
|
517
|
+
}, null, 2);
|
|
518
|
+
}
|
|
519
|
+
function renderNodeMigrationSnapshot() {
|
|
520
|
+
return JSON.stringify({
|
|
521
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
522
|
+
prevId: '00000000-0000-0000-0000-000000000000',
|
|
523
|
+
version: '7',
|
|
524
|
+
dialect: 'postgresql',
|
|
525
|
+
tables: {
|
|
526
|
+
'public.app_records': {
|
|
527
|
+
name: 'app_records',
|
|
528
|
+
schema: '',
|
|
529
|
+
columns: {
|
|
530
|
+
id: {
|
|
531
|
+
name: 'id',
|
|
532
|
+
type: 'integer',
|
|
533
|
+
primaryKey: true,
|
|
534
|
+
notNull: true,
|
|
535
|
+
identity: {
|
|
536
|
+
type: 'always',
|
|
537
|
+
name: 'app_records_id_seq',
|
|
538
|
+
schema: 'public',
|
|
539
|
+
increment: '1',
|
|
540
|
+
startWith: '1',
|
|
541
|
+
minValue: '1',
|
|
542
|
+
maxValue: '2147483647',
|
|
543
|
+
cache: '1',
|
|
544
|
+
cycle: false
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
name: {
|
|
548
|
+
name: 'name',
|
|
549
|
+
type: 'varchar(255)',
|
|
550
|
+
primaryKey: false,
|
|
551
|
+
notNull: true
|
|
552
|
+
},
|
|
553
|
+
created_at: {
|
|
554
|
+
name: 'created_at',
|
|
555
|
+
type: 'timestamp with time zone',
|
|
556
|
+
primaryKey: false,
|
|
557
|
+
notNull: true,
|
|
558
|
+
default: 'now()'
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
indexes: {},
|
|
562
|
+
foreignKeys: {},
|
|
563
|
+
compositePrimaryKeys: {},
|
|
564
|
+
uniqueConstraints: {},
|
|
565
|
+
policies: {},
|
|
566
|
+
checkConstraints: {},
|
|
567
|
+
isRLSEnabled: false
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
enums: {},
|
|
571
|
+
schemas: {},
|
|
572
|
+
sequences: {},
|
|
573
|
+
roles: {},
|
|
574
|
+
policies: {},
|
|
575
|
+
views: {},
|
|
576
|
+
_meta: {
|
|
577
|
+
columns: {},
|
|
578
|
+
schemas: {},
|
|
579
|
+
tables: {}
|
|
580
|
+
}
|
|
581
|
+
}, null, 2);
|
|
582
|
+
}
|
|
583
|
+
function addGoArtifacts(add, plan) {
|
|
584
|
+
add('go-backend-module', 'backend', ['backend', 'go.mod'], renderGoModule(plan));
|
|
585
|
+
add('go-backend-makefile', 'backend', ['backend', 'Makefile'], renderGoMakefile());
|
|
586
|
+
add('go-backend-main', 'backend', ['backend', 'cmd', 'api', 'main.go'], renderGoMain(plan));
|
|
587
|
+
add('go-backend-api', 'backend', ['backend', 'internal', 'api', 'api.go'], renderGoApi(plan));
|
|
588
|
+
add('go-backend-config', 'backend', ['backend', 'internal', 'config', 'config.go'], renderGoConfig(plan));
|
|
589
|
+
add('go-backend-database', 'backend', ['backend', 'internal', 'database', 'database.go'], renderGoDatabase());
|
|
590
|
+
add('go-backend-test-health', 'backend-test', ['backend', 'internal', 'api', 'api_test.go'], renderGoHealthTest(plan));
|
|
591
|
+
add('database-go-migration', 'database', ['database', 'migrations', '0001_initial.sql'], renderGoMigration());
|
|
592
|
+
add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderStandardSchema(plan));
|
|
593
|
+
}
|
|
594
|
+
function goModule(plan) {
|
|
595
|
+
return `example.com/${plan.packageName}/backend`;
|
|
596
|
+
}
|
|
597
|
+
function renderGoModule(plan) {
|
|
598
|
+
return `module ${goModule(plan)}
|
|
599
|
+
|
|
600
|
+
go 1.23
|
|
601
|
+
|
|
602
|
+
require (
|
|
603
|
+
github.com/danielgtaylor/huma/v2 v2.27.0
|
|
604
|
+
github.com/go-chi/chi/v5 v5.2.1
|
|
605
|
+
github.com/jackc/pgx/v5 v5.7.2
|
|
606
|
+
)
|
|
607
|
+
`;
|
|
608
|
+
}
|
|
609
|
+
function renderGoMakefile() {
|
|
610
|
+
return `GOOSE_VERSION := v3.24.1
|
|
611
|
+
|
|
612
|
+
.PHONY: test migrate
|
|
613
|
+
|
|
614
|
+
test:
|
|
615
|
+
go test ./...
|
|
616
|
+
|
|
617
|
+
migrate:
|
|
618
|
+
go run github.com/pressly/goose/v3/cmd/goose@$(GOOSE_VERSION) -dir ../database/migrations postgres "$(DATABASE_URL)" up
|
|
619
|
+
`;
|
|
620
|
+
}
|
|
621
|
+
function renderGoMain(plan) {
|
|
622
|
+
return `package main
|
|
623
|
+
|
|
624
|
+
import (
|
|
625
|
+
"log"
|
|
626
|
+
"net/http"
|
|
627
|
+
|
|
628
|
+
"${goModule(plan)}/internal/api"
|
|
629
|
+
"${goModule(plan)}/internal/config"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
func main() {
|
|
633
|
+
cfg, err := config.Load()
|
|
634
|
+
if err != nil {
|
|
635
|
+
log.Fatal(err)
|
|
636
|
+
}
|
|
637
|
+
log.Printf("%s listening on :%s", cfg.AppName, cfg.Port)
|
|
638
|
+
log.Fatal(http.ListenAndServe(":"+cfg.Port, api.New(cfg.AppName)))
|
|
639
|
+
}
|
|
640
|
+
`;
|
|
641
|
+
}
|
|
642
|
+
function renderGoApi(plan) {
|
|
643
|
+
return `package api
|
|
644
|
+
|
|
645
|
+
import (
|
|
646
|
+
"context"
|
|
647
|
+
"fmt"
|
|
648
|
+
"net/http"
|
|
649
|
+
|
|
650
|
+
"github.com/danielgtaylor/huma/v2"
|
|
651
|
+
"github.com/danielgtaylor/huma/v2/adapters/humachi"
|
|
652
|
+
"github.com/go-chi/chi/v5"
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
type statusOutput struct {
|
|
656
|
+
Body struct {
|
|
657
|
+
Status string \`json:"status"\`
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
func New(name string) http.Handler {
|
|
662
|
+
router := chi.NewRouter()
|
|
663
|
+
config := huma.DefaultConfig(name+" API", "0.1.0")
|
|
664
|
+
config.OpenAPIPath = "/openapi"
|
|
665
|
+
config.DocsPath = ""
|
|
666
|
+
api := humachi.New(router, config)
|
|
667
|
+
|
|
668
|
+
huma.Get(api, "/health", func(context.Context, *struct{}) (*statusOutput, error) {
|
|
669
|
+
output := &statusOutput{}
|
|
670
|
+
output.Body.Status = "ok"
|
|
671
|
+
return output, nil
|
|
672
|
+
})
|
|
673
|
+
huma.Get(api, "/ready", func(context.Context, *struct{}) (*statusOutput, error) {
|
|
674
|
+
output := &statusOutput{}
|
|
675
|
+
output.Body.Status = "ready"
|
|
676
|
+
return output, nil
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
router.Get("/api", func(response http.ResponseWriter, _ *http.Request) {
|
|
680
|
+
response.Header().Set("Content-Type", "application/json")
|
|
681
|
+
fmt.Fprint(response, ${sourceString(JSON.stringify({ name: plan.projectName, stack: 'go-huma' }))})
|
|
682
|
+
})
|
|
683
|
+
router.Get("/scalar", scalarReference)
|
|
684
|
+
return router
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
func scalarReference(response http.ResponseWriter, _ *http.Request) {
|
|
688
|
+
response.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
689
|
+
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>\`)
|
|
690
|
+
}
|
|
691
|
+
`;
|
|
692
|
+
}
|
|
693
|
+
function renderGoConfig(plan) {
|
|
694
|
+
return `package config
|
|
695
|
+
|
|
696
|
+
import (
|
|
697
|
+
"fmt"
|
|
698
|
+
"os"
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
type Config struct {
|
|
702
|
+
AppName string
|
|
703
|
+
AppEnv string
|
|
704
|
+
Port string
|
|
705
|
+
CloudProvider string
|
|
706
|
+
AzureRegion string
|
|
707
|
+
DatabaseURL string
|
|
708
|
+
RedisURL string
|
|
709
|
+
MessagingTransport string
|
|
710
|
+
BlobEndpoint string
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
func Load() (Config, error) {
|
|
714
|
+
databaseURL := os.Getenv("DATABASE_URL")
|
|
715
|
+
redisURL := os.Getenv("REDIS_URL")
|
|
716
|
+
if databaseURL == "" || redisURL == "" {
|
|
717
|
+
return Config{}, fmt.Errorf("DATABASE_URL and REDIS_URL are required")
|
|
718
|
+
}
|
|
719
|
+
return Config{
|
|
720
|
+
AppName: value("APP_NAME", ${sourceString(plan.projectName)}),
|
|
721
|
+
AppEnv: value("APP_ENV", "dev"),
|
|
722
|
+
Port: value("PORT", "8000"),
|
|
723
|
+
CloudProvider: value("CLOUD_PROVIDER", "${plan.provider.id}"),
|
|
724
|
+
AzureRegion: value("AZURE_REGION", "${plan.region.slug}"),
|
|
725
|
+
DatabaseURL: databaseURL,
|
|
726
|
+
RedisURL: redisURL,
|
|
727
|
+
MessagingTransport: value("MESSAGING_TRANSPORT", "redis-streams"),
|
|
728
|
+
BlobEndpoint: os.Getenv("BLOB_ENDPOINT"),
|
|
729
|
+
}, nil
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
func value(name, fallback string) string {
|
|
733
|
+
if current := os.Getenv(name); current != "" {
|
|
734
|
+
return current
|
|
735
|
+
}
|
|
736
|
+
return fallback
|
|
737
|
+
}
|
|
738
|
+
`;
|
|
739
|
+
}
|
|
740
|
+
function renderGoDatabase() {
|
|
741
|
+
return `package database
|
|
742
|
+
|
|
743
|
+
import (
|
|
744
|
+
"context"
|
|
745
|
+
|
|
746
|
+
"github.com/jackc/pgx/v5/pgxpool"
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
func Open(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
|
750
|
+
return pgxpool.New(ctx, databaseURL)
|
|
751
|
+
}
|
|
752
|
+
`;
|
|
753
|
+
}
|
|
754
|
+
function renderGoHealthTest(plan) {
|
|
755
|
+
return `package api
|
|
756
|
+
|
|
757
|
+
import (
|
|
758
|
+
"net/http"
|
|
759
|
+
"net/http/httptest"
|
|
760
|
+
"testing"
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
func TestHealthAndReady(t *testing.T) {
|
|
764
|
+
handler := New(${sourceString(plan.projectName)})
|
|
765
|
+
for _, path := range []string{"/health", "/ready"} {
|
|
766
|
+
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
767
|
+
response := httptest.NewRecorder()
|
|
768
|
+
handler.ServeHTTP(response, request)
|
|
769
|
+
if response.Code != http.StatusOK {
|
|
770
|
+
t.Fatalf("%s returned %d", path, response.Code)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
`;
|
|
775
|
+
}
|
|
776
|
+
function renderGoMigration() {
|
|
777
|
+
return `-- +goose Up
|
|
778
|
+
CREATE TABLE app_records (
|
|
779
|
+
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
780
|
+
name varchar(255) NOT NULL,
|
|
781
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
-- +goose Down
|
|
785
|
+
DROP TABLE app_records;
|
|
786
|
+
`;
|
|
787
|
+
}
|
|
788
|
+
function renderStandardSchema(plan) {
|
|
789
|
+
return `-- ${plan.safeProjectName} standard application schema
|
|
790
|
+
CREATE TABLE IF NOT EXISTS app_records (
|
|
791
|
+
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
792
|
+
name varchar(255) NOT NULL,
|
|
793
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
794
|
+
);
|
|
795
|
+
`;
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=standard-templates.js.map
|