@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.
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
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