@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/dist/templates.js CHANGED
@@ -1,20 +1,38 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { addGenAiExtensionArtifacts } from './genai-templates.js';
3
+ import { addStandardStackArtifacts, renderStandardDockerfile, renderStandardEnv } from './standard-templates.js';
2
4
  import { liftoffVersion } from './version.js';
3
5
  const contentHash = (content) => `sha256:${createHash('sha256').update(content, 'utf8').digest('hex')}`;
4
6
  const pyModule = (value) => value.replace(/-/g, '_');
5
7
  const titleCase = (value) => value.replace(/(^|[-_\s])([a-z])/g, (_match, prefix, letter) => `${prefix ? ' ' : ''}${letter.toUpperCase()}`).trim();
6
- const hasFunctionWorker = (plan) => plan.provider.id === 'azure' && plan.pattern.worker;
7
- const functionWorkerName = (plan) => `${plan.pattern.id}-worker`;
8
+ const sourceString = (value) => JSON.stringify(value);
9
+ const scriptSourceString = (value) => sourceString(value).replaceAll('<', '\\u003c');
10
+ const escapeHtml = (value) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
11
+ const genAiPattern = (plan) => {
12
+ if (plan.projectType.id !== 'genai' || !plan.pattern) {
13
+ throw new Error('GenAI template rendering requires a GenAI pattern.');
14
+ }
15
+ return plan.pattern;
16
+ };
17
+ const hasFunctionWorker = (plan) => plan.projectType.id === 'genai' && plan.provider.id === 'azure' && genAiPattern(plan).worker;
18
+ const functionWorkerName = (plan) => `${genAiPattern(plan).id}-worker`;
8
19
  export function buildArtifacts(plan) {
9
20
  const artifacts = [];
10
21
  const add = (logicalName, category, pathParts, content) => {
11
22
  artifacts.push({ logicalName, category, pathParts, content: ensureTrailingNewline(content) });
12
23
  };
13
24
  addBaseArtifacts(add, plan);
14
- addBackendArtifacts(add, plan);
15
- addDatabaseArtifacts(add, plan);
16
- addPatternArtifacts(add, plan);
17
- addFunctionArtifacts(add, plan);
25
+ if (plan.projectType.id === 'genai') {
26
+ addGenAiExtensionArtifacts(add, plan, {
27
+ backend: addBackendArtifacts,
28
+ database: addDatabaseArtifacts,
29
+ pattern: addPatternArtifacts,
30
+ functions: addFunctionArtifacts
31
+ });
32
+ }
33
+ else {
34
+ addStandardStackArtifacts(add, plan);
35
+ }
18
36
  addEnvironmentArtifacts(add, plan);
19
37
  addDockerArtifacts(add, plan);
20
38
  addInfrastructureArtifacts(add, plan);
@@ -38,7 +56,9 @@ export function buildManifest(plan, artifacts) {
38
56
  liftoffVersion,
39
57
  project: {
40
58
  name: plan.projectName,
41
- pattern: plan.pattern.id,
59
+ projectType: plan.projectType.id,
60
+ apiStack: plan.apiStack.id,
61
+ ...(plan.pattern ? { pattern: plan.pattern.id } : {}),
42
62
  cloud: plan.provider.id,
43
63
  region: plan.region.slug,
44
64
  frontend: plan.includeFrontend,
@@ -60,7 +80,9 @@ function addBaseArtifacts(add, plan) {
60
80
  add('root-gitignore', 'project', ['.gitignore'], renderGeneratedGitignore());
61
81
  add('liftoff-config', 'project', ['liftoff.config.json'], JSON.stringify({
62
82
  projectName: plan.projectName,
63
- pattern: plan.pattern.id,
83
+ projectType: plan.projectType.id,
84
+ apiStack: plan.apiStack.id,
85
+ ...(plan.pattern ? { pattern: plan.pattern.id } : {}),
64
86
  cloud: plan.provider.id,
65
87
  region: plan.region.slug,
66
88
  includeFrontend: plan.includeFrontend,
@@ -68,10 +90,10 @@ function addBaseArtifacts(add, plan) {
68
90
  specWorkflow: plan.specWorkflow.id
69
91
  }, null, 2));
70
92
  add('env-example', 'configuration', ['.env.example'], renderEnvExample(plan));
71
- add('backend-dockerfile', 'runtime', ['Dockerfile'], renderBackendDockerfile());
93
+ add('backend-dockerfile', 'runtime', ['Dockerfile'], plan.projectType.id === 'genai' ? renderBackendDockerfile() : renderStandardDockerfile(plan));
72
94
  }
73
95
  function addBackendArtifacts(add, plan) {
74
- const routeModule = pyModule(plan.pattern.id);
96
+ const routeModule = pyModule(genAiPattern(plan).id);
75
97
  add('backend-pyproject', 'backend', ['backend', 'pyproject.toml'], renderBackendPyproject(plan));
76
98
  add('backend-package', 'backend', ['backend', '__init__.py'], '');
77
99
  add('backend-api-package', 'backend', ['backend', 'apis', '__init__.py'], '');
@@ -97,20 +119,21 @@ function addDatabaseArtifacts(add, plan) {
97
119
  add('database-schema', 'database', ['database', 'models', 'schema.sql'], renderDatabaseSchema(plan));
98
120
  }
99
121
  function addPatternArtifacts(add, plan) {
100
- const routeModule = pyModule(plan.pattern.id);
122
+ const pattern = genAiPattern(plan);
123
+ const routeModule = pyModule(pattern.id);
101
124
  add('pattern-agent', 'pattern', ['backend', 'orchestration', 'agents', `${routeModule}_agent.py`], renderPatternAgent(plan));
102
- add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${plan.pattern.id}.md`], renderPromptTemplate(plan));
125
+ add('pattern-prompt', 'pattern', ['backend', 'orchestration', 'prompts', `${pattern.id}.md`], renderPromptTemplate(plan));
103
126
  add('pattern-agent-package', 'pattern', ['backend', 'orchestration', 'agents', '__init__.py'], '');
104
127
  add('pattern-prompt-readme', 'pattern', ['backend', 'orchestration', 'prompts', 'README.md'], renderPromptReadme());
105
- if (plan.pattern.id === 'rag') {
128
+ if (pattern.id === 'rag') {
106
129
  add('rag-vector-store', 'pattern', ['backend', 'orchestration', 'retrieval', 'vector_store.py'], renderVectorStore());
107
130
  add('rag-retrieval-package', 'pattern', ['backend', 'orchestration', 'retrieval', '__init__.py'], '');
108
131
  }
109
- if (plan.pattern.worker) {
132
+ if (pattern.worker) {
110
133
  add('pattern-worker', 'pattern', ['backend', 'workers', `${routeModule}_worker.py`], renderPatternWorker(plan));
111
134
  add('backend-workers-package', 'pattern', ['backend', 'workers', '__init__.py'], '');
112
135
  }
113
- if (plan.pattern.id === 'fine-tuned') {
136
+ if (pattern.id === 'fine-tuned') {
114
137
  add('fine-tuned-eval-dataset', 'pattern', ['backend', 'evaluation', 'datasets', 'sample.jsonl'], '{"input":"Example request","expected":"Expected response placeholder"}');
115
138
  }
116
139
  }
@@ -132,7 +155,7 @@ function addFunctionArtifacts(add, plan) {
132
155
  }
133
156
  function addEnvironmentArtifacts(add, plan) {
134
157
  for (const environment of plan.environments) {
135
- add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], renderBackendEnv(plan, environment.id));
158
+ add(`environment-${environment.id}-backend`, 'environment', ['environments', environment.id, 'backend.env'], plan.projectType.id === 'genai' ? renderBackendEnv(plan, environment.id) : renderStandardEnv(plan, environment.id));
136
159
  if (hasFunctionWorker(plan)) {
137
160
  add(`environment-${environment.id}-functions`, 'environment', ['environments', environment.id, 'functions.env'], renderFunctionsEnv(plan, environment.id));
138
161
  }
@@ -183,6 +206,48 @@ function addFrontendArtifacts(add, plan) {
183
206
  add('frontend-dockerfile', 'frontend', ['frontend', 'Dockerfile'], renderFrontendDockerfile());
184
207
  }
185
208
  function renderRootReadme(plan) {
209
+ if (plan.projectType.id === 'standard') {
210
+ return `# ${plan.projectName}
211
+
212
+ Generated by Mission Control Liftoff.
213
+
214
+ ## Stack
215
+
216
+ - Project type: Standard application
217
+ - API: ${plan.apiStack.label}
218
+ - Database tooling: ${plan.apiStack.databaseTooling}
219
+ - API reference: Scalar with OpenAPI
220
+ - Cloud: ${plan.provider.label} (${plan.region.slug})
221
+ - Infrastructure: OpenTofu
222
+ - Database: PostgreSQL
223
+ - Cache and local messaging: Redis
224
+ - Local development: Docker Compose
225
+ ${plan.includeFrontend ? '- Frontend: Vue 3 with Tailwind\n' : ''}
226
+ ## Local Development
227
+
228
+ \`\`\`bash
229
+ docker compose up --build
230
+ \`\`\`
231
+
232
+ The backend API is available on port 8000. Health and readiness endpoints are available at \`/health\` and \`/ready\`; Scalar is exposed at \`/scalar\`.
233
+
234
+ ## Infrastructure
235
+
236
+ \`\`\`bash
237
+ cd infrastructure/opentofu/azure
238
+ tofu init
239
+ tofu plan -var-file=environments/dev.tfvars
240
+ tofu apply -var-file=environments/dev.tfvars
241
+ \`\`\`
242
+
243
+ The first apply uses a public bootstrap image. Follow \`infrastructure/opentofu/azure/README.md\` to build the generated backend in ACR and apply its image.
244
+
245
+ ## Spec-Driven Workflow
246
+
247
+ Selected workflow: ${plan.specWorkflow.label}.
248
+ `;
249
+ }
250
+ const pattern = genAiPattern(plan);
186
251
  const functionsStackLine = hasFunctionWorker(plan) ? `- Azure Functions worker: Python v2 Service Bus trigger under \`functions/${functionWorkerName(plan)}\`
187
252
  ` : '';
188
253
  const functionsSection = hasFunctionWorker(plan) ? `
@@ -197,10 +262,10 @@ Generated by Mission Control Liftoff.
197
262
  ## Stack
198
263
 
199
264
  - Backend: FastAPI, PydanticAI, Pydantic settings, Scalar
200
- - Pattern: ${plan.pattern.label}
265
+ - Pattern: ${pattern.label}
201
266
  - Cloud: ${plan.provider.label} (${plan.region.slug})
202
267
  - Infrastructure: OpenTofu
203
- - Database: PostgreSQL with Alembic migrations${plan.pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
268
+ - Database: PostgreSQL with Alembic migrations${pattern.id === 'rag' ? ' and pgvector retrieval' : ''}
204
269
  - Cache and local messaging: Redis
205
270
  - Observability: Langfuse
206
271
  - Local development: Docker Compose
@@ -224,6 +289,8 @@ tofu plan -var-file=environments/dev.tfvars
224
289
  tofu apply -var-file=environments/dev.tfvars
225
290
  \`\`\`
226
291
 
292
+ The first apply uses a public bootstrap image. Follow \`infrastructure/opentofu/azure/README.md\` to build the generated backend in ACR and apply its image.
293
+
227
294
  ## Spec-Driven Workflow
228
295
 
229
296
  Selected workflow: ${plan.specWorkflow.label}.
@@ -244,9 +311,13 @@ migration/legacy/
244
311
  `;
245
312
  }
246
313
  function renderEnvExample(plan) {
314
+ if (plan.projectType.id === 'standard') {
315
+ return renderStandardEnv(plan);
316
+ }
317
+ const pattern = genAiPattern(plan);
247
318
  return `APP_ENV=dev
248
319
  APP_NAME=${plan.safeProjectName}
249
- GENAI_PATTERN=${plan.pattern.id}
320
+ GENAI_PATTERN=${pattern.id}
250
321
  CLOUD_PROVIDER=${plan.provider.id}
251
322
  AZURE_REGION=${plan.region.slug}
252
323
  DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
@@ -289,6 +360,7 @@ dependencies = [
289
360
  "scalar-fastapi>=1.0",
290
361
  "sqlalchemy[asyncio]>=2.0",
291
362
  "asyncpg>=0.29",
363
+ "psycopg[binary]>=3.2",
292
364
  "alembic>=1.13",
293
365
  "redis>=5.0",
294
366
  "langfuse>=2.39",
@@ -304,6 +376,9 @@ test = ["pytest>=8.2", "httpx>=0.27"]
304
376
  requires = ["setuptools>=70"]
305
377
  build-backend = "setuptools.build_meta"
306
378
 
379
+ [tool.setuptools]
380
+ packages = []
381
+
307
382
  [tool.pytest.ini_options]
308
383
  pythonpath = [".."]
309
384
  testpaths = ["tests"]
@@ -352,16 +427,17 @@ def ready():
352
427
  `;
353
428
  }
354
429
  function renderPatternRoutes(plan) {
355
- const moduleName = pyModule(plan.pattern.id);
430
+ const pattern = genAiPattern(plan);
431
+ const moduleName = pyModule(pattern.id);
356
432
  const agentName = `${moduleName}_agent`;
357
- const prefix = plan.pattern.routePrefix;
358
- if (plan.pattern.id === 'streaming') {
433
+ const prefix = pattern.routePrefix;
434
+ if (pattern.id === 'streaming') {
359
435
  return `from fastapi import APIRouter
360
436
  from fastapi.responses import StreamingResponse
361
437
 
362
438
  from backend.orchestration.agents.${agentName} import stream_response
363
439
 
364
- router = APIRouter(prefix="${prefix}", tags=["${plan.pattern.id}"])
440
+ router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
365
441
 
366
442
 
367
443
  @router.get("")
@@ -369,7 +445,7 @@ def stream(prompt: str):
369
445
  return StreamingResponse(stream_response(prompt), media_type="text/event-stream")
370
446
  `;
371
447
  }
372
- if (plan.pattern.id === 'rag') {
448
+ if (pattern.id === 'rag') {
373
449
  return `from fastapi import APIRouter
374
450
  from pydantic import BaseModel
375
451
 
@@ -396,13 +472,13 @@ async def ingest(request: IngestionRequest):
396
472
  return await enqueue_ingestion(request.source_uri)
397
473
  `;
398
474
  }
399
- const bodyClass = `${titleCase(plan.pattern.id).replace(/\s/g, '')}Request`;
475
+ const bodyClass = `${titleCase(pattern.id).replace(/\s/g, '')}Request`;
400
476
  return `from fastapi import APIRouter
401
477
  from pydantic import BaseModel
402
478
 
403
479
  from backend.orchestration.agents.${agentName} import run_${moduleName}
404
480
 
405
- router = APIRouter(prefix="${prefix}", tags=["${plan.pattern.id}"])
481
+ router = APIRouter(prefix="${prefix}", tags=["${pattern.id}"])
406
482
 
407
483
 
408
484
  class ${bodyClass}(BaseModel):
@@ -428,6 +504,7 @@ async def get_current_user() -> CurrentUser:
428
504
  `;
429
505
  }
430
506
  function renderSettings(plan) {
507
+ const pattern = genAiPattern(plan);
431
508
  return `from functools import lru_cache
432
509
  from pydantic_settings import BaseSettings, SettingsConfigDict
433
510
 
@@ -435,9 +512,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
435
512
  class Settings(BaseSettings):
436
513
  model_config = SettingsConfigDict(env_file=".env", extra="ignore")
437
514
 
438
- app_name: str = "${plan.projectName}"
515
+ app_name: str = ${sourceString(plan.projectName)}
439
516
  app_env: str = "dev"
440
- genai_pattern: str = "${plan.pattern.id}"
517
+ genai_pattern: str = "${pattern.id}"
441
518
  cloud_provider: str = "${plan.provider.id}"
442
519
  azure_region: str = "${plan.region.slug}"
443
520
  database_url: str
@@ -453,6 +530,7 @@ def get_settings() -> Settings:
453
530
  `;
454
531
  }
455
532
  function renderModelConfig(plan) {
533
+ const pattern = genAiPattern(plan);
456
534
  return `from pydantic import BaseModel, Field
457
535
 
458
536
 
@@ -460,7 +538,7 @@ class ModelConfig(BaseModel):
460
538
  provider: str = Field(default="azure-openai")
461
539
  deployment_name: str = Field(default="gpt-4.1")
462
540
  embedding_deployment_name: str = Field(default="text-embedding-3-large")
463
- pattern: str = Field(default="${plan.pattern.id}")
541
+ pattern: str = Field(default="${pattern.id}")
464
542
  `;
465
543
  }
466
544
  function renderMessagingBoundary() {
@@ -515,25 +593,35 @@ def test_health():
515
593
  }
516
594
  function renderAlembicIni() {
517
595
  return `[alembic]
518
- script_location = migrations
596
+ script_location = %(here)s/migrations
519
597
  sqlalchemy.url = driver://user:pass@localhost/dbname
520
598
  `;
521
599
  }
522
600
  function renderAlembicEnv() {
523
- return `from alembic import context
601
+ return `import os
602
+
603
+ from alembic import context
604
+ from sqlalchemy import create_engine
524
605
 
525
606
 
526
607
  def run_migrations_online():
527
- context.configure(url=context.get_x_argument(as_dictionary=True).get("database_url"))
528
- with context.begin_transaction():
529
- context.run_migrations()
608
+ database_url = os.environ.get("DATABASE_URL")
609
+ if not database_url:
610
+ raise RuntimeError("DATABASE_URL is required to run migrations")
611
+ database_url = database_url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1)
612
+ database_url = database_url.replace("postgresql://", "postgresql+psycopg://", 1)
613
+ connectable = create_engine(database_url)
614
+ with connectable.connect() as connection:
615
+ context.configure(connection=connection, target_metadata=None)
616
+ with context.begin_transaction():
617
+ context.run_migrations()
530
618
 
531
619
 
532
620
  run_migrations_online()
533
621
  `;
534
622
  }
535
623
  function renderInitialMigration(plan) {
536
- const vectorExtension = plan.pattern.id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
624
+ const vectorExtension = genAiPattern(plan).id === 'rag' ? ' op.execute("CREATE EXTENSION IF NOT EXISTS vector")\n' : '';
537
625
  return `from alembic import op
538
626
  import sqlalchemy as sa
539
627
 
@@ -564,11 +652,12 @@ function renderDatabaseSchema(plan) {
564
652
  payload JSONB NOT NULL,
565
653
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
566
654
  );
567
- ${plan.pattern.id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
655
+ ${genAiPattern(plan).id === 'rag' ? '\nCREATE EXTENSION IF NOT EXISTS vector;\n' : ''}`;
568
656
  }
569
657
  function renderPatternAgent(plan) {
570
- const moduleName = pyModule(plan.pattern.id);
571
- if (plan.pattern.id === 'rag') {
658
+ const pattern = genAiPattern(plan);
659
+ const moduleName = pyModule(pattern.id);
660
+ if (pattern.id === 'rag') {
572
661
  return `from backend.orchestration.tools.messaging import build_message_publisher
573
662
 
574
663
 
@@ -586,7 +675,7 @@ async def enqueue_ingestion(source_uri: str) -> dict:
586
675
  return {"status": "queued", "source_uri": source_uri}
587
676
  `;
588
677
  }
589
- if (plan.pattern.id === 'streaming') {
678
+ if (pattern.id === 'streaming') {
590
679
  return `async def stream_response(prompt: str):
591
680
  yield f"data: Starting response for {prompt}\\n\\n"
592
681
  yield "data: Replace this placeholder with PydanticAI streaming orchestration.\\n\\n"
@@ -594,15 +683,16 @@ async def enqueue_ingestion(source_uri: str) -> dict:
594
683
  }
595
684
  return `async def run_${moduleName}(input_text: str) -> dict:
596
685
  return {
597
- "result": "Replace this placeholder with ${plan.pattern.label} PydanticAI orchestration.",
686
+ "result": "Replace this placeholder with ${pattern.label} PydanticAI orchestration.",
598
687
  "input": input_text,
599
688
  }
600
689
  `;
601
690
  }
602
691
  function renderPromptTemplate(plan) {
603
- return `# ${plan.pattern.label} Prompt
692
+ const pattern = genAiPattern(plan);
693
+ return `# ${pattern.label} Prompt
604
694
 
605
- You are implementing a ${plan.pattern.label} generated by Mission Control Liftoff.
695
+ You are implementing a ${pattern.label} generated by Mission Control Liftoff.
606
696
 
607
697
  Use PydanticAI orchestration and return outputs that match the API contract.
608
698
  `;
@@ -628,8 +718,9 @@ class PgVectorStore:
628
718
  `;
629
719
  }
630
720
  function renderPatternWorker(plan) {
721
+ const pattern = genAiPattern(plan);
631
722
  return `async def run_worker() -> None:
632
- # Consume ${plan.pattern.label} jobs from the configured messaging boundary.
723
+ # Consume ${pattern.label} jobs from the configured messaging boundary.
633
724
  return None
634
725
  `;
635
726
  }
@@ -643,9 +734,10 @@ Keep reusable GenAI orchestration, model configuration, prompt handling, and dom
643
734
  }
644
735
  function renderFunctionWorkerReadme(plan) {
645
736
  const workerName = functionWorkerName(plan);
737
+ const pattern = genAiPattern(plan);
646
738
  return `# ${workerName}
647
739
 
648
- Azure Functions worker scaffold for ${plan.pattern.label}.
740
+ Azure Functions worker scaffold for ${pattern.label}.
649
741
 
650
742
  This Function app uses the Python v2 decorator programming model and a Service Bus queue trigger. The trigger adapter should stay thin: decode the message, validate the envelope, and call shared code from \`backend/orchestration\` after that shared code is packaged with the Function app.
651
743
 
@@ -672,6 +764,7 @@ function renderFunctionHostJson() {
672
764
  }, null, 2);
673
765
  }
674
766
  function renderFunctionLocalSettings(plan) {
767
+ const pattern = genAiPattern(plan);
675
768
  return JSON.stringify({
676
769
  IsEncrypted: false,
677
770
  Values: {
@@ -679,7 +772,7 @@ function renderFunctionLocalSettings(plan) {
679
772
  FUNCTIONS_WORKER_RUNTIME: 'python',
680
773
  SERVICEBUS_QUEUE_NAME: 'events',
681
774
  ServiceBusConnection__fullyQualifiedNamespace: '<service-bus-namespace>.servicebus.windows.net',
682
- GENAI_PATTERN: plan.pattern.id,
775
+ GENAI_PATTERN: pattern.id,
683
776
  SHARED_ORCHESTRATION_ROOT: '../../backend'
684
777
  }
685
778
  }, null, 2);
@@ -690,7 +783,8 @@ pytest>=8.2
690
783
  `;
691
784
  }
692
785
  function renderFunctionApp(plan) {
693
- const moduleName = pyModule(plan.pattern.id);
786
+ const pattern = genAiPattern(plan);
787
+ const moduleName = pyModule(pattern.id);
694
788
  return `import json
695
789
  import logging
696
790
 
@@ -717,7 +811,7 @@ def decode_message_payload(body: str) -> dict:
717
811
  )
718
812
  def process_${moduleName}_work(message: func.ServiceBusMessage) -> None:
719
813
  payload = decode_message_payload(message.get_body().decode("utf-8"))
720
- logging.info("Received ${plan.pattern.id} worker message with keys: %s", sorted(payload.keys()))
814
+ logging.info("Received ${pattern.id} worker message with keys: %s", sorted(payload.keys()))
721
815
  # Keep this adapter thin; call backend.orchestration code from packaged shared modules.
722
816
  `;
723
817
  }
@@ -751,10 +845,11 @@ local.settings.json
751
845
  `;
752
846
  }
753
847
  function renderBackendEnv(plan, environment) {
848
+ const pattern = genAiPattern(plan);
754
849
  const transport = environment === 'dev' ? 'redis-streams' : 'azure-service-bus';
755
850
  return `APP_ENV=${environment}
756
851
  APP_NAME=${plan.safeProjectName}
757
- GENAI_PATTERN=${plan.pattern.id}
852
+ GENAI_PATTERN=${pattern.id}
758
853
  CLOUD_PROVIDER=${plan.provider.id}
759
854
  AZURE_REGION=${plan.region.slug}
760
855
  DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/${plan.safeProjectName.replace(/-/g, '_')}
@@ -765,9 +860,10 @@ LANGFUSE_HOST=
765
860
  `;
766
861
  }
767
862
  function renderFunctionsEnv(plan, environment) {
863
+ const pattern = genAiPattern(plan);
768
864
  return `APP_ENV=${environment}
769
865
  APP_NAME=${plan.safeProjectName}
770
- GENAI_PATTERN=${plan.pattern.id}
866
+ GENAI_PATTERN=${pattern.id}
771
867
  FUNCTIONS_WORKER_RUNTIME=python
772
868
  SERVICEBUS_QUEUE_NAME=events
773
869
  ServiceBusConnection__fullyQualifiedNamespace=<service-bus-namespace>.servicebus.windows.net
@@ -776,6 +872,7 @@ SHARED_ORCHESTRATION_ROOT=../../backend
776
872
  `;
777
873
  }
778
874
  function renderDockerCompose(plan) {
875
+ const localEnvironment = plan.environments.find((environment) => environment.id === 'dev') ?? plan.environments[0];
779
876
  const frontendService = plan.includeFrontend ? `
780
877
  frontend:
781
878
  build:
@@ -785,13 +882,17 @@ function renderDockerCompose(plan) {
785
882
  depends_on:
786
883
  - backend
787
884
  ` : '';
885
+ const postgresImage = plan.projectType.id === 'genai' ? 'pgvector/pgvector:pg16' : 'postgres:16-alpine';
788
886
  return `services:
789
887
  backend:
790
888
  build:
791
889
  context: .
792
890
  dockerfile: Dockerfile
793
891
  env_file:
794
- - ./environments/dev/backend.env
892
+ - ./environments/${localEnvironment.id}/backend.env
893
+ environment:
894
+ MESSAGING_TRANSPORT: redis-streams
895
+ BLOB_ENDPOINT: http://azurite:10000/devstoreaccount1
795
896
  ports:
796
897
  - "8000:8000"
797
898
  depends_on:
@@ -801,7 +902,7 @@ function renderDockerCompose(plan) {
801
902
  - mailpit
802
903
  ${frontendService}
803
904
  postgres:
804
- image: pgvector/pgvector:pg16
905
+ image: ${postgresImage}
805
906
  environment:
806
907
  POSTGRES_USER: postgres
807
908
  POSTGRES_PASSWORD: postgres
@@ -825,7 +926,7 @@ ${frontendService}
825
926
  ports:
826
927
  - "8025:8025"
827
928
 
828
- langfuse:
929
+ ${plan.projectType.id === 'genai' ? ` langfuse:
829
930
  image: langfuse/langfuse:2
830
931
  profiles:
831
932
  - observability
@@ -835,6 +936,7 @@ ${frontendService}
835
936
  SALT: local-development-placeholder
836
937
  ports:
837
938
  - "3000:3000"
939
+ ` : ''}
838
940
  `;
839
941
  }
840
942
  function renderTofuVersions() {
@@ -856,6 +958,13 @@ function renderTofuProviders() {
856
958
  `;
857
959
  }
858
960
  function renderTofuVariables(plan) {
961
+ const frontendVariables = plan.includeFrontend ? `
962
+ variable "frontend_image" {
963
+ type = string
964
+ default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
965
+ description = "Frontend image. Replace the bootstrap image with the generated frontend image after pushing it to ACR."
966
+ }
967
+ ` : '';
859
968
  const functionVariables = hasFunctionWorker(plan) ? `
860
969
  variable "function_worker_queue_name" {
861
970
  type = string
@@ -885,6 +994,18 @@ variable "resource_suffix" {
885
994
  description = "Globally unique suffix for Azure resource names."
886
995
  }
887
996
 
997
+ variable "backend_image" {
998
+ type = string
999
+ default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
1000
+ description = "Backend image. Replace the bootstrap image with the generated backend image after pushing it to ACR."
1001
+ }
1002
+
1003
+ variable "backend_target_port" {
1004
+ type = number
1005
+ default = 80
1006
+ description = "Backend ingress port. Set to 8000 when switching from the bootstrap image to the generated backend."
1007
+ }
1008
+ ${frontendVariables}
888
1009
  variable "postgres_admin_password" {
889
1010
  type = string
890
1011
  sensitive = true
@@ -900,6 +1021,18 @@ ${functionVariables}
900
1021
  `;
901
1022
  }
902
1023
  function renderTofuMain(plan) {
1024
+ const functionPattern = hasFunctionWorker(plan) ? genAiPattern(plan) : undefined;
1025
+ const projectIdentityEnv = plan.projectType.id === 'genai' ? `
1026
+ env {
1027
+ name = "GENAI_PATTERN"
1028
+ value = "${genAiPattern(plan).id}"
1029
+ }
1030
+ ` : `
1031
+ env {
1032
+ name = "API_STACK"
1033
+ value = "${plan.apiStack.id}"
1034
+ }
1035
+ `;
903
1036
  const frontendContainer = plan.includeFrontend ? `
904
1037
  resource "azurerm_container_app" "frontend" {
905
1038
  name = "ca-${plan.safeProjectName}-frontend-\${var.environment}"
@@ -907,10 +1040,20 @@ resource "azurerm_container_app" "frontend" {
907
1040
  resource_group_name = azurerm_resource_group.main.name
908
1041
  revision_mode = "Single"
909
1042
 
1043
+ identity {
1044
+ type = "UserAssigned"
1045
+ identity_ids = [azurerm_user_assigned_identity.app.id]
1046
+ }
1047
+
1048
+ registry {
1049
+ server = azurerm_container_registry.main.login_server
1050
+ identity = azurerm_user_assigned_identity.app.id
1051
+ }
1052
+
910
1053
  template {
911
1054
  container {
912
1055
  name = "frontend"
913
- image = "\${azurerm_container_registry.main.login_server}/${plan.safeProjectName}-frontend:latest"
1056
+ image = var.frontend_image
914
1057
  cpu = 0.25
915
1058
  memory = "0.5Gi"
916
1059
  }
@@ -924,6 +1067,8 @@ resource "azurerm_container_app" "frontend" {
924
1067
  latest_revision = true
925
1068
  }
926
1069
  }
1070
+
1071
+ depends_on = [azurerm_role_assignment.acr_pull]
927
1072
  }
928
1073
  ` : '';
929
1074
  const functionWorker = hasFunctionWorker(plan) ? `
@@ -957,7 +1102,7 @@ resource "azurerm_linux_function_app" "worker" {
957
1102
  app_settings = {
958
1103
  APP_ENV = var.environment
959
1104
  APP_NAME = "${plan.safeProjectName}"
960
- GENAI_PATTERN = "${plan.pattern.id}"
1105
+ GENAI_PATTERN = "${functionPattern?.id}"
961
1106
  FUNCTIONS_WORKER_RUNTIME = "python"
962
1107
  SERVICEBUS_QUEUE_NAME = var.function_worker_queue_name
963
1108
  ServiceBusConnection__fullyQualifiedNamespace = "\${azurerm_servicebus_namespace.main.name}.servicebus.windows.net"
@@ -1001,6 +1146,12 @@ resource "azurerm_user_assigned_identity" "app" {
1001
1146
  location = azurerm_resource_group.main.location
1002
1147
  }
1003
1148
 
1149
+ resource "azurerm_role_assignment" "acr_pull" {
1150
+ scope = azurerm_container_registry.main.id
1151
+ role_definition_name = "AcrPull"
1152
+ principal_id = azurerm_user_assigned_identity.app.principal_id
1153
+ }
1154
+
1004
1155
  resource "azurerm_container_app_environment" "main" {
1005
1156
  name = "cae-\${local.name_prefix}"
1006
1157
  resource_group_name = azurerm_resource_group.main.name
@@ -1018,23 +1169,85 @@ resource "azurerm_container_app" "backend" {
1018
1169
  identity_ids = [azurerm_user_assigned_identity.app.id]
1019
1170
  }
1020
1171
 
1172
+ registry {
1173
+ server = azurerm_container_registry.main.login_server
1174
+ identity = azurerm_user_assigned_identity.app.id
1175
+ }
1176
+
1177
+ secret {
1178
+ name = "database-url"
1179
+ value = "postgresql://liftoffadmin:\${urlencode(var.postgres_admin_password)}@\${azurerm_postgresql_flexible_server.main.fqdn}:5432/postgres?sslmode=require"
1180
+ }
1181
+
1182
+ secret {
1183
+ name = "redis-url"
1184
+ value = "rediss://:\${urlencode(azurerm_redis_cache.main.primary_access_key)}@\${azurerm_redis_cache.main.hostname}:\${azurerm_redis_cache.main.ssl_port}/0"
1185
+ }
1186
+
1021
1187
  template {
1022
1188
  container {
1023
1189
  name = "backend"
1024
- image = "\${azurerm_container_registry.main.login_server}/${plan.safeProjectName}-backend:latest"
1190
+ image = var.backend_image
1025
1191
  cpu = 0.5
1026
1192
  memory = "1Gi"
1193
+
1194
+ env {
1195
+ name = "APP_ENV"
1196
+ value = var.environment
1197
+ }
1198
+
1199
+ env {
1200
+ name = "APP_NAME"
1201
+ value = "${plan.safeProjectName}"
1202
+ }
1203
+
1204
+ env {
1205
+ name = "PROJECT_TYPE"
1206
+ value = "${plan.projectType.id}"
1207
+ }
1208
+ ${projectIdentityEnv}
1209
+ env {
1210
+ name = "CLOUD_PROVIDER"
1211
+ value = "azure"
1212
+ }
1213
+
1214
+ env {
1215
+ name = "AZURE_REGION"
1216
+ value = var.location
1217
+ }
1218
+
1219
+ env {
1220
+ name = "DATABASE_URL"
1221
+ secret_name = "database-url"
1222
+ }
1223
+
1224
+ env {
1225
+ name = "REDIS_URL"
1226
+ secret_name = "redis-url"
1227
+ }
1228
+
1229
+ env {
1230
+ name = "MESSAGING_TRANSPORT"
1231
+ value = "azure-service-bus"
1232
+ }
1233
+
1234
+ env {
1235
+ name = "BLOB_ENDPOINT"
1236
+ value = azurerm_storage_account.main.primary_blob_endpoint
1237
+ }
1027
1238
  }
1028
1239
  }
1029
1240
 
1030
1241
  ingress {
1031
1242
  external_enabled = true
1032
- target_port = 8000
1243
+ target_port = var.backend_target_port
1033
1244
  traffic_weight {
1034
1245
  percentage = 100
1035
1246
  latest_revision = true
1036
1247
  }
1037
1248
  }
1249
+
1250
+ depends_on = [azurerm_role_assignment.acr_pull]
1038
1251
  }
1039
1252
  ${frontendContainer}
1040
1253
  resource "azurerm_postgresql_flexible_server" "main" {
@@ -1048,6 +1261,14 @@ resource "azurerm_postgresql_flexible_server" "main" {
1048
1261
  sku_name = "B_Standard_B1ms"
1049
1262
  }
1050
1263
 
1264
+ resource "azurerm_postgresql_flexible_server_firewall_rule" "azure_services" {
1265
+ count = var.enable_private_networking ? 0 : 1
1266
+ name = "AllowAzureServices"
1267
+ server_id = azurerm_postgresql_flexible_server.main.id
1268
+ start_ip_address = "0.0.0.0"
1269
+ end_ip_address = "0.0.0.0"
1270
+ }
1271
+
1051
1272
  resource "azurerm_redis_cache" "main" {
1052
1273
  name = "redis-\${local.name_prefix}-\${var.resource_suffix}"
1053
1274
  location = azurerm_resource_group.main.location
@@ -1121,6 +1342,10 @@ ${plan.includeFrontend ? `output "frontend_url" {
1121
1342
  ` : ''}${functionOutputs}output "container_registry" {
1122
1343
  value = azurerm_container_registry.main.login_server
1123
1344
  }
1345
+
1346
+ output "container_registry_name" {
1347
+ value = azurerm_container_registry.main.name
1348
+ }
1124
1349
  `;
1125
1350
  }
1126
1351
  function renderTofuLocalState() {
@@ -1151,30 +1376,98 @@ This project includes an Azure Functions worker under \`functions/${functionWork
1151
1376
 
1152
1377
  Azure is the complete V1 provider for this Liftoff project.
1153
1378
 
1379
+ ## Bootstrap Infrastructure
1380
+
1381
+ The first apply uses a public bootstrap image so Azure Container Apps can start before the new ACR contains application images.
1382
+
1154
1383
  \`\`\`bash
1155
1384
  tofu init
1156
1385
  tofu plan -var-file=environments/${env}.tfvars
1157
1386
  tofu apply -var-file=environments/${env}.tfvars
1158
- tofu output
1387
+ \`\`\`
1388
+
1389
+ Build the generated backend in ACR, then replace the bootstrap image:
1390
+
1391
+ \`\`\`bash
1392
+ ACR_NAME="$(tofu output -raw container_registry_name)"
1393
+ az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-backend:latest ../../..
1394
+ ${plan.includeFrontend ? `az acr build --registry "$ACR_NAME" --image ${plan.safeProjectName}-frontend:latest ../../../frontend
1395
+ ` : ''}\`\`\`
1396
+
1397
+ Persist the deployed images in \`environments/${env}.tfvars\` so future applies do not restore the bootstrap image:
1398
+
1399
+ \`\`\`hcl
1400
+ backend_image = "<login-server>/${plan.safeProjectName}-backend:latest"
1401
+ backend_target_port = 8000
1402
+ ${plan.includeFrontend ? `frontend_image = "<login-server>/${plan.safeProjectName}-frontend:latest"
1403
+ ` : ''}\`\`\`
1404
+
1405
+ \`\`\`bash
1406
+ tofu apply -var-file=environments/${env}.tfvars
1159
1407
  \`\`\`
1160
1408
 
1161
1409
  Local OpenTofu state is generated by default. Use \`backend.remote.example.tf\` as the starting point for team remote state.
1410
+ The default PostgreSQL firewall permits Azure-hosted services. Replace it with private networking before production; set \`enable_private_networking=true\` only when the required VNet, delegated subnet, and private DNS resources are added.
1162
1411
  ${functionSection}
1163
1412
  `;
1164
1413
  }
1165
1414
  function renderTofuTfvars(plan, environment) {
1166
- const functionValues = hasFunctionWorker(plan) ? `function_worker_queue_name = "events"
1167
- functions_python_version = "3.12"
1168
- ` : '';
1169
- return `environment = "${environment}"
1170
- location = "${plan.region.slug}"
1171
- resource_suffix = "${plan.safeProjectName.replace(/-/g, '')}${environment}"
1172
- enable_private_networking = ${environment === 'prod' ? 'true' : 'false'}
1173
- ${functionValues}
1174
- `;
1415
+ const values = [
1416
+ ['environment', JSON.stringify(environment)],
1417
+ ['location', JSON.stringify(plan.region.slug)],
1418
+ ['resource_suffix', JSON.stringify(`${plan.safeProjectName.replace(/-/g, '')}${environment}`)],
1419
+ ['backend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')],
1420
+ ['backend_target_port', '80'],
1421
+ ['enable_private_networking', 'false']
1422
+ ];
1423
+ if (plan.includeFrontend) {
1424
+ values.push(['frontend_image', JSON.stringify('mcr.microsoft.com/azuredocs/containerapps-helloworld:latest')]);
1425
+ }
1426
+ if (hasFunctionWorker(plan)) {
1427
+ values.push(['function_worker_queue_name', JSON.stringify('events')], ['functions_python_version', JSON.stringify('3.12')]);
1428
+ }
1429
+ const width = Math.max(...values.map(([key]) => key.length));
1430
+ return values.map(([key, value]) => `${key.padEnd(width)} = ${value}`).join('\n');
1175
1431
  }
1176
1432
  function renderOpenSpecConfig(plan) {
1177
1433
  const frontendRule = plan.includeFrontend ? '\n - Keep frontend code under frontend.' : '';
1434
+ if (plan.projectType.id === 'standard') {
1435
+ const backendRule = plan.apiStack.id === 'python-fastapi'
1436
+ ? 'Keep backend API code under backend/apis.'
1437
+ : plan.apiStack.id === 'node-fastify'
1438
+ ? 'Keep backend API code under backend/src.'
1439
+ : 'Keep the Go entrypoint under backend/cmd/api and reusable code under backend/internal.';
1440
+ return `schema: spec-driven
1441
+
1442
+ context: |
1443
+ Project generated by Mission Control Liftoff.
1444
+ Project type: Standard application.
1445
+ API stack: ${plan.apiStack.label}.
1446
+ Database tooling: ${plan.apiStack.databaseTooling}.
1447
+ API developer portal: Scalar.
1448
+ Infrastructure: OpenTofu.
1449
+ Primary cloud: Azure (${plan.region.slug}).
1450
+ Local development: Docker Compose.
1451
+ Database: PostgreSQL.
1452
+ Cache and local messaging: Redis.
1453
+ Environments: ${plan.environments.map((environment) => environment.id).join(', ')}.
1454
+
1455
+ rules:
1456
+ specs:
1457
+ - Requirements must describe observable product behavior.
1458
+ - Cloud behavior must identify environment differences for generated environments.
1459
+ design:
1460
+ - Use ${plan.apiStack.framework} for backend APIs.
1461
+ - Use ${plan.apiStack.databaseTooling} for database access and migrations.
1462
+ - Use OpenTofu for infrastructure changes.${frontendRule}
1463
+ - ${backendRule}
1464
+ - Keep database artifacts under database.
1465
+ tasks:
1466
+ - Include local Docker Compose verification.
1467
+ - Include OpenTofu validation for generated infrastructure.
1468
+ `;
1469
+ }
1470
+ const pattern = genAiPattern(plan);
1178
1471
  const functionsContext = hasFunctionWorker(plan) ? `
1179
1472
  Azure Functions worker: functions/${functionWorkerName(plan)}.` : '';
1180
1473
  const functionsRule = hasFunctionWorker(plan) ? `
@@ -1184,7 +1477,7 @@ function renderOpenSpecConfig(plan) {
1184
1477
 
1185
1478
  context: |
1186
1479
  Project generated by Mission Control Liftoff.
1187
- GenAI pattern: ${plan.pattern.label}
1480
+ GenAI pattern: ${pattern.label}
1188
1481
  Application framework: FastAPI + PydanticAI.
1189
1482
  API developer portal: Scalar.
1190
1483
  Infrastructure: OpenTofu.
@@ -1213,10 +1506,36 @@ ${functionsRule}
1213
1506
  `;
1214
1507
  }
1215
1508
  function renderSeedProposal(plan) {
1509
+ if (plan.projectType.id === 'standard') {
1510
+ return `## Why
1511
+
1512
+ Bootstrap the generated ${plan.apiStack.label} standard application baseline created by Mission Control Liftoff.
1513
+
1514
+ ## What Changes
1515
+
1516
+ - Establish the approved backend, infrastructure, local development, and governance baseline.
1517
+ - Capture follow-up product requirements through spec-driven changes.
1518
+
1519
+ ## Capabilities
1520
+
1521
+ ### New Capabilities
1522
+
1523
+ - \`${plan.apiStack.id}-application-baseline\`: Generated standard application baseline for this Liftoff project.
1524
+
1525
+ ### Modified Capabilities
1526
+
1527
+ - None.
1528
+
1529
+ ## Impact
1530
+
1531
+ - Generated ${plan.apiStack.label} backend, OpenTofu infrastructure, Docker Compose local development, and governance files.
1532
+ `;
1533
+ }
1534
+ const pattern = genAiPattern(plan);
1216
1535
  const functionsChange = hasFunctionWorker(plan) ? '\n- Establish Azure Functions worker trigger adapters for event-driven processing.' : '';
1217
1536
  return `## Why
1218
1537
 
1219
- Bootstrap the generated ${plan.pattern.label} application baseline created by Mission Control Liftoff.
1538
+ Bootstrap the generated ${pattern.label} application baseline created by Mission Control Liftoff.
1220
1539
 
1221
1540
  ## What Changes
1222
1541
 
@@ -1228,7 +1547,7 @@ ${functionsChange}
1228
1547
 
1229
1548
  ### New Capabilities
1230
1549
 
1231
- - \`${plan.pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
1550
+ - \`${pattern.id}-application-baseline\`: Generated application baseline for this Liftoff project.
1232
1551
 
1233
1552
  ### Modified Capabilities
1234
1553
 
@@ -1240,10 +1559,38 @@ ${functionsChange}
1240
1559
  `;
1241
1560
  }
1242
1561
  function renderSeedDesign(plan) {
1562
+ if (plan.projectType.id === 'standard') {
1563
+ return `## Context
1564
+
1565
+ This standard project was generated with Liftoff using ${plan.apiStack.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
1566
+
1567
+ ## Goals / Non-Goals
1568
+
1569
+ **Goals:**
1570
+
1571
+ - Keep the generated baseline aligned to the approved Mission Control stack.
1572
+
1573
+ **Non-Goals:**
1574
+
1575
+ - Define domain-specific product behavior in the bootstrap change.
1576
+
1577
+ ## Decisions
1578
+
1579
+ - Use ${plan.apiStack.framework} for backend APIs.
1580
+ - Use ${plan.apiStack.databaseTooling} for PostgreSQL integration.
1581
+ - Use OpenTofu for Azure infrastructure.
1582
+ - Use Docker Compose for local development.
1583
+
1584
+ ## Risks / Trade-offs
1585
+
1586
+ - The baseline contains placeholders that product-specific changes should replace.
1587
+ `;
1588
+ }
1589
+ const pattern = genAiPattern(plan);
1243
1590
  const functionsDecision = hasFunctionWorker(plan) ? '\n- Keep Azure Functions trigger adapters under functions/' + functionWorkerName(plan) + ' and shared GenAI logic under backend/orchestration.' : '';
1244
1591
  return `## Context
1245
1592
 
1246
- This project was generated with Liftoff using ${plan.pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
1593
+ This project was generated with Liftoff using ${pattern.label}, Azure, OpenTofu, and ${plan.specWorkflow.label}.
1247
1594
 
1248
1595
  ## Goals / Non-Goals
1249
1596
 
@@ -1276,6 +1623,28 @@ function renderSeedTasks() {
1276
1623
  `;
1277
1624
  }
1278
1625
  function renderSpecKitConstitution(plan) {
1626
+ if (plan.projectType.id === 'standard') {
1627
+ const backendLayout = plan.apiStack.id === 'python-fastapi'
1628
+ ? 'backend/apis'
1629
+ : plan.apiStack.id === 'node-fastify' ? 'backend/src' : 'backend/cmd/api and backend/internal';
1630
+ return `# Mission Control Liftoff Constitution
1631
+
1632
+ ## Principle 1: Approved Application Stack
1633
+ Generated backend services MUST use ${plan.apiStack.framework}, ${plan.apiStack.databaseTooling}, and Scalar for API documentation.
1634
+
1635
+ ## Principle 2: Standard Project Layout
1636
+ Backend APIs live under ${backendLayout}. Database artifacts live under database.${plan.includeFrontend ? ' Frontend code lives under frontend.' : ''}
1637
+
1638
+ ## Principle 3: Infrastructure As Code
1639
+ Cloud infrastructure MUST be defined with OpenTofu. Azure is the supported V1 provider.
1640
+
1641
+ ## Principle 4: Local Development Parity
1642
+ Projects MUST include Docker Compose for local development with PostgreSQL, Redis, local blob storage, and local messaging behavior.
1643
+
1644
+ ## Principle 5: Observability And Operations
1645
+ Services MUST use structured logging and environment-specific configuration for ${plan.environments.map((environment) => environment.id).join(', ')}.
1646
+ `;
1647
+ }
1279
1648
  const functionsLayout = hasFunctionWorker(plan) ? ` Azure Functions trigger adapters live under functions/${functionWorkerName(plan)} and call shared orchestration from backend/orchestration.` : '';
1280
1649
  return `# Mission Control Liftoff Constitution
1281
1650
 
@@ -1337,7 +1706,7 @@ function renderFrontendPackage(plan) {
1337
1706
  }, null, 2);
1338
1707
  }
1339
1708
  function renderFrontendIndex(plan) {
1340
- return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${plan.projectName}</title>`;
1709
+ return `<div id="app"></div><script type="module" src="/src/main.ts"></script><title>${escapeHtml(plan.projectName)}</title>`;
1341
1710
  }
1342
1711
  function renderFrontendMain() {
1343
1712
  return `import { createApp } from 'vue';
@@ -1348,10 +1717,11 @@ createApp(App).mount('#app');
1348
1717
  `;
1349
1718
  }
1350
1719
  function renderFrontendApp(plan) {
1720
+ const descriptor = plan.projectType.id === 'genai' ? `${genAiPattern(plan).label} starter` : `${plan.apiStack.label} starter`;
1351
1721
  return `<script setup lang="ts">
1352
- const title = '${plan.projectName}';
1353
- const starter = '${plan.frontendStarter}';
1354
- const pattern = '${plan.pattern.label}';
1722
+ const title = ${scriptSourceString(plan.projectName)};
1723
+ const starter = ${scriptSourceString(plan.frontendStarter)};
1724
+ const descriptor = ${scriptSourceString(descriptor)};
1355
1725
  </script>
1356
1726
 
1357
1727
  <template>
@@ -1360,7 +1730,7 @@ const pattern = '${plan.pattern.label}';
1360
1730
  <header>
1361
1731
  <p class="text-sm font-semibold uppercase tracking-wide text-emerald-700">Mission Control Liftoff</p>
1362
1732
  <h1 class="mt-2 text-3xl font-bold">{{ title }}</h1>
1363
- <p class="mt-2 text-slate-600">{{ pattern }} starter</p>
1733
+ <p class="mt-2 text-slate-600">{{ descriptor }}</p>
1364
1734
  </header>
1365
1735
  <section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
1366
1736
  <h2 class="text-xl font-semibold">{{ starter }}</h2>