diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5fc18..6488b1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,15 +35,15 @@ jobs: matrix: group: - name: frontend-core - args: --scenario landing-react-vite --scenario frontend-nextjs --scenario frontend-react-vite-netlify --scenario frontend-astro --scenario frontend-remix-bun + args: --scenario landing-react-vite --scenario frontend-nextjs --scenario frontend-nextjs-railway --scenario frontend-react-vite-netlify --scenario frontend-astro --scenario frontend-remix-bun - name: frontend-apps args: --scenario frontend-vue-vite --scenario frontend-nuxt --scenario frontend-svelte --scenario frontend-solidjs - name: backends - args: --scenario backend-express --scenario backend-fastify --scenario backend-hono --scenario backend-koa-javascript --scenario backend-nestjs + args: --scenario backend-express --scenario backend-fastify --scenario backend-fastify-render --scenario backend-hono --scenario backend-koa-javascript --scenario backend-nestjs --scenario backend-nestjs-enterprise-pnpm - name: fullstack-workspaces args: --scenario fullstack-nextjs --scenario fullstack-react-api --scenario microfrontend-workspace - name: extensions-cli - args: --scenario chrome-extension-react --scenario chrome-extension-vanilla-ts --scenario cli-tool + args: --scenario chrome-extension-react --scenario chrome-extension-react-jest-pnpm --scenario chrome-extension-vanilla-ts --scenario cli-tool steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index a781412..31d1084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ The format follows Keep a Changelog and the version numbers follow Semantic Vers ## [Unreleased] +## [0.5.1] - 2026-04-09 + +### Added + +- Added a deployment profile layer that records provider-specific build commands, start commands, ports, health paths, generated files, secrets, and environment-variable expectations for verified scaffold pairs. +- Added `Render` deployment baselines for verified `react-vite` frontend apps and `express`, `fastify`, and `hono` backend APIs, including generated `render.yaml` output and deploy workflow guidance. +- Added `Railway` deployment baselines for verified `nextjs` frontend apps and `express`, `fastify`, and `hono` backend APIs, including generated `railway.toml` output and deploy workflow guidance. +- Added deployment-profile regression coverage, provider-specific generator assertions, runtime-matrix scenarios for `frontend-nextjs-railway` and `backend-fastify-render`, and packed-artifact smoke coverage for deployment-target upgrades. +- Added deployment-aware `.env.example`, README, architecture, and getting-started outputs so generated projects surface provider env vars and deployment expectations alongside local run commands. +- Added end-to-end runtime coverage for capability-heavy `NestJS + Fastify + pnpm + Jest` backends and `React Chrome extension + pnpm + Jest` scaffolds so non-default package-manager and test-runner paths are exercised as generated projects. + +### Changed + +- Expanded deployment-aware scaffold docs and CLI guidance so generated projects now explain build commands, start commands, health checks, provider secrets, and expected environment variables for the selected deployment target. +- Updated `devforge upgrade` so deployment target changes can remove stale managed provider files such as `vercel.json`, `netlify.toml`, `render.yaml`, `railway.toml`, and `docker-compose.yml` when those files still match a previous generated baseline. +- Updated generated backend and Next.js runtime commands to bind to deployment-friendly hosts, which makes managed hosting baselines more usable without manual edits. +- Expanded CI and packed-artifact smoke coverage so the shipped npm package now exercises deployment-target refresh against `Render`, while source CI validates the new `Railway` and `Render` runtime scenarios directly. +- Clarified backend capability prompts and generated guidance so authentication, database, ORM, Redis, Swagger, and WebSocket selections are described as starter baselines unless the scaffold fully wires them. + +### Fixed + +- Fixed deployment-target support so verified managed-hosting targets are exercised by the runtime matrix and packed npm smoke path instead of relying only on unit-level generation checks. +- Fixed deployment upgrade behavior for users switching providers by cleaning up obsolete managed deployment files even when the stored project plan has already been normalized to the new target. +- Fixed generated Jest example tests so TypeScript projects include explicit Jest type references during `build` and `typecheck` instead of depending on missing ambient globals. +- Fixed generated Jest configs so TypeScript 6 based scaffolds ignore the known `ts-jest` deprecation codes that would otherwise break `pnpm run test` on browser-oriented projects. +- Fixed NestJS Fastify scaffolds so the generated server bootstrap uses `@nestjs/platform-fastify` instead of falling back to the default Express driver. +- Fixed runtime-matrix package-manager installs so `pnpm` and `yarn` scenarios can run through Corepack when the package manager is not globally installed. +- Fixed the runtime matrix to cover the exact `NestJS + Fastify + pnpm + Jest` backend path and `React Chrome extension + pnpm + Jest` path that had previously escaped end-to-end validation. +- Fixed runtime-matrix `pnpm` fallbacks on CI runners by switching the no-global-`pnpm` path away from Corepack key resolution and onto an npm-backed `pnpm` invocation. + ## [0.5.0] - 2026-04-02 ### Added diff --git a/README.md b/README.md index 36215df..d2fd48b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ DevForge helps you: - AI rule outputs for Cursor, Claude, Codex, and `AGENTS.md` - testing setup for Vitest, Jest, Playwright, and Cypress - optional ESLint, Prettier, Husky, Commitlint, Docker, and GitHub Actions setup -- deployment baselines for verified Vercel, Netlify, and Docker Compose scaffold pairs +- deployment baselines for verified Vercel, Netlify, Render, Railway, and Docker Compose scaffold pairs - generated docs, changelog-ready project metadata, and baseline repository hygiene ## Quick Start @@ -170,10 +170,10 @@ npm run runtime:matrix -- --scenario backend-hono --scenario cli-tool ## Deployment Targets - Verified deployment targets are offered only for supported stack pairs. -- `react-vite` frontend apps can target `Vercel` or `Netlify`. -- `nextjs` frontend apps can target `Vercel`. -- `express`, `fastify`, and `hono` backend APIs can target `Docker Compose`. -- Deployment generation includes target-specific config files and optional manual deploy workflows when GitHub Actions is enabled. +- `react-vite` frontend apps can target `Vercel`, `Netlify`, or `Render`. +- `nextjs` frontend apps can target `Vercel`, `Render`, or `Railway`. +- `express`, `fastify`, and `hono` backend APIs can target `Docker Compose`, `Render`, or `Railway`. +- Deployment generation includes provider-specific config files, build/start/health-path guidance, expected env vars, and optional manual deploy workflows when GitHub Actions are enabled. ## Repository Docs diff --git a/docs/changelog.md b/docs/changelog.md index 6f85cf6..dc8439c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,36 @@ Track what changed in DevForge CLI across releases, including scaffolding behavi - [GitHub Releases](https://github.com/Ali-dev11/devforge/releases) - [Repository Changelog](https://github.com/Ali-dev11/devforge/blob/main/CHANGELOG.md) +## [0.5.1] - 2026-04-09 + +### Added + +- Added a deployment profile layer that records provider-specific build commands, start commands, ports, health paths, generated files, secrets, and environment-variable expectations for verified scaffold pairs. +- Added `Render` deployment baselines for verified `react-vite` frontend apps and `express`, `fastify`, and `hono` backend APIs, including generated `render.yaml` output and deploy workflow guidance. +- Added `Railway` deployment baselines for verified `nextjs` frontend apps and `express`, `fastify`, and `hono` backend APIs, including generated `railway.toml` output and deploy workflow guidance. +- Added deployment-profile regression coverage, provider-specific generator assertions, runtime-matrix scenarios for `frontend-nextjs-railway` and `backend-fastify-render`, and packed-artifact smoke coverage for deployment-target upgrades. +- Added deployment-aware `.env.example`, README, architecture, and getting-started outputs so generated projects surface provider env vars and deployment expectations alongside local run commands. +- Added end-to-end runtime coverage for capability-heavy `NestJS + Fastify + pnpm + Jest` backends and `React Chrome extension + pnpm + Jest` scaffolds so non-default package-manager and test-runner paths are exercised as generated projects. + +### Changed + +- Expanded deployment-aware scaffold docs and CLI guidance so generated projects now explain build commands, start commands, health checks, provider secrets, and expected environment variables for the selected deployment target. +- Updated `devforge upgrade` so deployment target changes can remove stale managed provider files such as `vercel.json`, `netlify.toml`, `render.yaml`, `railway.toml`, and `docker-compose.yml` when those files still match a previous generated baseline. +- Updated generated backend and Next.js runtime commands to bind to deployment-friendly hosts, which makes managed hosting baselines more usable without manual edits. +- Expanded CI and packed-artifact smoke coverage so the shipped npm package now exercises deployment-target refresh against `Render`, while source CI validates the new `Railway` and `Render` runtime scenarios directly. +- Clarified backend capability prompts and generated guidance so authentication, database, ORM, Redis, Swagger, and WebSocket selections are described as starter baselines unless the scaffold fully wires them. + +### Fixed + +- Fixed deployment-target support so verified managed-hosting targets are exercised by the runtime matrix and packed npm smoke path instead of relying only on unit-level generation checks. +- Fixed deployment upgrade behavior for users switching providers by cleaning up obsolete managed deployment files even when the stored project plan has already been normalized to the new target. +- Fixed generated Jest example tests so TypeScript projects include explicit Jest type references during `build` and `typecheck` instead of depending on missing ambient globals. +- Fixed generated Jest configs so TypeScript 6 based scaffolds ignore the known `ts-jest` deprecation codes that would otherwise break `pnpm run test` on browser-oriented projects. +- Fixed NestJS Fastify scaffolds so the generated server bootstrap uses `@nestjs/platform-fastify` instead of falling back to the default Express driver. +- Fixed runtime-matrix package-manager installs so `pnpm` and `yarn` scenarios can run through Corepack when the package manager is not globally installed. +- Fixed the runtime matrix to cover the exact `NestJS + Fastify + pnpm + Jest` backend path and `React Chrome extension + pnpm + Jest` path that had previously escaped end-to-end validation. +- Fixed runtime-matrix `pnpm` fallbacks on CI runners by switching the no-global-`pnpm` path away from Corepack key resolution and onto an npm-backed `pnpm` invocation. + ## [0.5.0] - 2026-04-02 ### Added diff --git a/docs/development.md b/docs/development.md index 8077a7e..8d00721 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,6 +34,7 @@ npx --yes @ali-dev11/devforge@latest add testing - Config-driven runs pass through the same normalization and compatibility checks as interactive runs, so invalid hand-written combinations are still corrected or rejected with guidance. - `devforge upgrade` refreshes DevForge-managed docs, workflows, AI rules, deployment files, and core tooling surfaces while skipping files that no longer match the previous generated baseline. - `devforge add ` updates an existing DevForge project by reading `.devforge/project-plan.json` and rewriting only the managed files for that feature. +- Verified deployment targets now carry a normalized provider profile, so generated docs and workflows stay aligned on build commands, start commands, health checks, secrets, and expected environment variables. ## Repository Commands @@ -72,9 +73,11 @@ npm run runtime:matrix -- --scenario backend-hono --scenario cli-tool - Use `npx --yes @ali-dev11/devforge@latest init --save-config` when you want an interactive run to become a reusable team preset later. - Use `npx --yes @ali-dev11/devforge@latest init --config ./devforge.config.json --output ./my-app` when validating reproducible scaffold output. - Use `npx --yes @ali-dev11/devforge@latest upgrade` when changing generated docs, workflows, deployment baselines, or other managed tooling surfaces. +- When changing deployment targets, verify both the generated provider file and the follow-up workflow guidance because `upgrade` now removes stale managed provider files that still match a previous generated baseline. - Use `npx --yes @ali-dev11/devforge@latest add testing`, `add docker`, `add github-actions`, or `add ai-rules` when validating managed post-scaffold updates. - Use `npm run runtime:matrix` when changing templates, prompts, package-manager behavior, or generated runtime surfaces. - Use `npm run smoke:packed` when changing the package entrypoints, published files, CLI dispatch, or install-time behavior. +- If you touch deployment generation, validate `Render` and `Railway` flows through `npm run runtime:matrix` or `npm run smoke:packed`, not just unit-level file snapshots. - If you touch microfrontend templates, validate the generated `dev` workflow, not just build output. - If you touch docs or release notes, rerun `npm run docs:changelog`. diff --git a/docs/index.md b/docs/index.md index ca24d58..7f0787d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,7 @@ DevForge CLI turns project intent into a runnable JavaScript or TypeScript repos - ESLint and Prettier are baseline defaults because most teams want immediate quality and formatting consistency. - Husky and Commitlint are optional because local git-hook enforcement varies by team and workflow. - Testing, AI rules, backend capabilities, frontend libraries, and DevOps extras can all stay on recommended defaults or be customized only when needed. -- Deployment targets are limited to verified stack pairs so DevForge only offers deployment baselines it can actually generate and validate. +- Deployment targets are limited to verified stack pairs so DevForge only offers deployment baselines it can actually generate and validate, including the current `Vercel`, `Netlify`, `Render`, `Railway`, and `Docker Compose` paths. ## Command Reference diff --git a/package-lock.json b/package-lock.json index e4df39f..3b6ec29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ali-dev11/devforge", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ali-dev11/devforge", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "prompts": "^2.4.2" diff --git a/package.json b/package.json index a1c6c58..80c5faf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ali-dev11/devforge", - "version": "0.5.0", + "version": "0.5.1", "description": "Production-focused AI-native project scaffolding CLI for JavaScript and TypeScript teams.", "license": "MIT", "author": "Ali-dev11", diff --git a/scripts/smoke-packed.mjs b/scripts/smoke-packed.mjs index e75cab8..9d81674 100644 --- a/scripts/smoke-packed.mjs +++ b/scripts/smoke-packed.mjs @@ -92,7 +92,7 @@ try { const storedPlanPath = resolve(outputDir, ".devforge/project-plan.json"); const storedPlan = JSON.parse(readFileSync(storedPlanPath, "utf8")); - storedPlan.deployment.target = "vercel"; + storedPlan.deployment.target = "render"; writeFileSync(storedPlanPath, `${JSON.stringify(storedPlan, null, 2)}\n`, "utf8"); run( @@ -123,8 +123,8 @@ try { throw new Error("Packed smoke run did not create Dockerfile after `devforge add docker`."); } - if (!existsSync(resolve(outputDir, "vercel.json"))) { - throw new Error("Packed smoke run did not create vercel.json after `devforge upgrade`."); + if (!existsSync(resolve(outputDir, "render.yaml"))) { + throw new Error("Packed smoke run did not create render.yaml after `devforge upgrade`."); } } finally { rmSync(workspace, { recursive: true, force: true }); diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index a72d718..e6776dc 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -16,7 +16,7 @@ import type { InstallResult, ProjectPlan, } from "../types.js"; -import { pathExists, readJson, writeJson, writeTextFile } from "../utils/fs.js"; +import { pathExists, readJson, removeFile, writeJson, writeTextFile } from "../utils/fs.js"; import { banner, info, step, success, warn } from "../utils/logger.js"; export type ApplyUpgradeOptions = { @@ -41,6 +41,14 @@ type PackageJsonShape = { engines?: Record; }; +const TARGET_EXCLUSIVE_MANAGED_PATHS = [ + "vercel.json", + "netlify.toml", + "render.yaml", + "railway.toml", + "docker-compose.yml", +] as const; + function printAdvisorySection( title: string, items: AdvisoryItem[], @@ -115,6 +123,36 @@ function buildGeneratedFileMap( return new Map(files.map((file) => [file.path, file])); } +function buildTargetExclusiveManagedContentCandidates( + plan: ProjectPlan, + environment: EnvironmentInfo, + path: (typeof TARGET_EXCLUSIVE_MANAGED_PATHS)[number], +): Set { + const candidates = new Set(); + const targets: ProjectPlan["deployment"]["target"][] = [ + "vercel", + "netlify", + "render", + "railway", + "docker-compose", + ]; + + for (const target of targets) { + const variant: ProjectPlan = JSON.parse(JSON.stringify(plan)) as ProjectPlan; + variant.deployment.target = target; + if (target === "docker-compose") { + variant.tooling.docker = true; + } + + const content = buildGeneratedFileMap(variant, environment).get(path)?.content; + if (content) { + candidates.add(content); + } + } + + return candidates; +} + function shouldManagePathForUpgrade(path: string): boolean { return ( path === "README.md" || @@ -139,6 +177,8 @@ function shouldManagePathForUpgrade(path: string): boolean { path === "docker-compose.yml" || path === "vercel.json" || path === "netlify.toml" || + path === "render.yaml" || + path === "railway.toml" || path === "AGENTS.md" || path === "docs/architecture.md" || path === "docs/getting-started.md" || @@ -351,6 +391,50 @@ export async function applyUpgrade( skippedManaged.push(relativePath); } + for (const [relativePath, previousFile] of previousGenerated.entries()) { + if (!shouldManagePathForUpgrade(relativePath) || nextGenerated.has(relativePath)) { + continue; + } + + const absolutePath = join(cwd, relativePath); + const currentContent = await readTextFileIfExists(absolutePath); + + if (currentContent === undefined) { + continue; + } + + if (currentContent === previousFile.content || hasManagedMarker(currentContent)) { + await removeFile(absolutePath); + filesWritten.push(absolutePath); + continue; + } + + skippedManaged.push(relativePath); + } + + for (const relativePath of TARGET_EXCLUSIVE_MANAGED_PATHS) { + if (nextGenerated.has(relativePath)) { + continue; + } + + const absolutePath = join(cwd, relativePath); + const currentContent = await readTextFileIfExists(absolutePath); + if (currentContent === undefined) { + continue; + } + + const generatedCandidates = buildTargetExclusiveManagedContentCandidates( + plan, + environment, + relativePath, + ); + + if (generatedCandidates.has(currentContent)) { + await removeFile(absolutePath); + filesWritten.push(absolutePath); + } + } + const projectPlanPath = join(cwd, PROJECT_PLAN_PATH); const previousPlanJson = JSON.stringify(previousPlan, null, 2); const nextPlanJson = JSON.stringify(plan, null, 2); diff --git a/src/constants.ts b/src/constants.ts index 2016987..8ac6ba9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -202,6 +202,8 @@ export const DEPLOYMENT_TARGET_CHOICES: Array<{ { title: "None", value: "none" }, { title: "Vercel", value: "vercel" }, { title: "Netlify", value: "netlify" }, + { title: "Render", value: "render" }, + { title: "Railway", value: "railway" }, { title: "Docker Compose", value: "docker-compose" }, ]; diff --git a/src/engines/prompts.ts b/src/engines/prompts.ts index c5e954c..e8242ae 100644 --- a/src/engines/prompts.ts +++ b/src/engines/prompts.ts @@ -761,15 +761,15 @@ export async function collectProjectPlan( ) : {}; const backendSetupAnswers = await prompts( - [ - { - type: "toggle", - name: "customizeBackend", - message: "Configure backend capabilities?", - initial: false, - active: "yes", - inactive: "no", - }, + [ + { + type: "toggle", + name: "customizeBackend", + message: "Configure backend capability baselines?", + initial: false, + active: "yes", + inactive: "no", + }, ], { onCancel: cancelHandler }, ); @@ -806,13 +806,13 @@ export async function collectProjectPlan( { type: "multiselect", name: "auth", - message: "Authentication", + message: "Authentication baselines", choices: withSelected(AUTH_CHOICES, plan.backend?.auth ?? []), }, { type: "select", name: "database", - message: "Database", + message: "Database baseline", choices: getDatabaseChoicesForBackend(plan.backend), initial: getInitialChoiceIndex( getDatabaseChoicesForBackend(plan.backend), @@ -832,7 +832,7 @@ export async function collectProjectPlan( { type: "select", name: "orm", - message: "ORM / query layer", + message: "ORM / query layer baseline", choices: getOrmChoicesForBackend(compatibleBackend), initial: getInitialChoiceIndex( getOrmChoicesForBackend(compatibleBackend), @@ -842,7 +842,7 @@ export async function collectProjectPlan( { type: "toggle", name: "redis", - message: "Add Redis?", + message: "Add Redis baseline?", initial: plan.backend?.redis ?? false, active: "yes", inactive: "no", @@ -850,7 +850,7 @@ export async function collectProjectPlan( { type: "toggle", name: "swagger", - message: "Add Swagger docs?", + message: "Add Swagger baseline?", initial: plan.backend?.swagger ?? true, active: "yes", inactive: "no", @@ -858,7 +858,7 @@ export async function collectProjectPlan( { type: "toggle", name: "websockets", - message: "Add WebSockets?", + message: "Add WebSockets baseline?", initial: plan.backend?.websockets ?? false, active: "yes", inactive: "no", diff --git a/src/generated/package-metadata.ts b/src/generated/package-metadata.ts index de8dbcc..55d57a7 100644 --- a/src/generated/package-metadata.ts +++ b/src/generated/package-metadata.ts @@ -1,5 +1,5 @@ // This file is generated by scripts/sync-package-metadata.mjs. // Do not edit it manually. -export const DEVFORGE_VERSION = "0.5.0"; +export const DEVFORGE_VERSION = "0.5.1"; export const DEVFORGE_PACKAGE_NAME = "@ali-dev11/devforge"; export const DEVFORGE_AUTHOR = "Ali-dev11"; diff --git a/src/guidance.ts b/src/guidance.ts index 0a019c0..af4afc1 100644 --- a/src/guidance.ts +++ b/src/guidance.ts @@ -3,7 +3,9 @@ import type { ArchitectureMode, BackendFramework, BackendLanguage, + DeploymentProfile, DeploymentTarget, + DeploymentVariable, EnvironmentInfo, FrontendFramework, FrontendRenderingMode, @@ -34,6 +36,7 @@ import { isNodeVersionSupportedForPlan, minimumSupportedNodeVersionHint, } from "./utils/node-compat.js"; +import { joinSentence, toTitleCase } from "./utils/strings.js"; const ALL_PACKAGE_MANAGERS: PackageManager[] = ["npm", "pnpm", "yarn", "bun"]; const ALL_UI_LIBRARIES: UiLibrary[] = [ @@ -89,6 +92,10 @@ export function deploymentTargetLabel(target: DeploymentTarget): string { return "Vercel"; case "netlify": return "Netlify"; + case "render": + return "Render"; + case "railway": + return "Railway"; case "docker-compose": return "Docker Compose"; case "none": @@ -142,6 +149,203 @@ export function packageManagerRunCommand( } } +function deploymentPortForPlan(plan: ProjectPlan): number | undefined { + if (plan.intent === "backend-api") { + return plan.deployment.port ?? 3001; + } + + if (plan.frontend?.framework === "nextjs") { + return plan.deployment.port ?? 3000; + } + + return plan.deployment.port; +} + +function deploymentHealthPathForPlan(plan: ProjectPlan): string | undefined { + if (plan.deployment.healthPath) { + return plan.deployment.healthPath; + } + + if (plan.intent === "backend-api") { + return "/health"; + } + + if (plan.frontend?.framework === "nextjs") { + return "/"; + } + + return undefined; +} + +function makeDeploymentVariable( + name: string, + description: string, + options: { example?: string; secret?: boolean } = {}, +): DeploymentVariable { + return { + name, + description, + example: options.example, + secret: options.secret, + }; +} + +export function getDeploymentProfile( + plan: ProjectPlan, +): DeploymentProfile | undefined { + const buildCommand = packageManagerRunCommand(plan.packageManager, "build"); + const startCommand = + plan.intent === "backend-api" || plan.frontend?.framework === "nextjs" + ? packageManagerRunCommand(plan.packageManager, "start") + : undefined; + const port = deploymentPortForPlan(plan); + const healthPath = deploymentHealthPathForPlan(plan); + + switch (plan.deployment.target) { + case "vercel": + return { + target: "vercel", + label: "Vercel", + category: plan.frontend?.framework === "react-vite" ? "static-site" : "managed-node", + buildCommand, + outputDirectory: plan.frontend?.framework === "react-vite" ? "dist" : undefined, + generatedFiles: ["vercel.json", ...(plan.tooling.githubActions ? [".github/workflows/deploy.yml"] : [])], + secrets: [ + makeDeploymentVariable("VERCEL_TOKEN", "Vercel access token for CI or manual deploy workflows.", { + secret: true, + }), + makeDeploymentVariable("VERCEL_ORG_ID", "Vercel team or account identifier used by the generated workflow.", { + secret: true, + }), + makeDeploymentVariable("VERCEL_PROJECT_ID", "Vercel project identifier used by the generated workflow.", { + secret: true, + }), + ], + environmentVariables: [], + }; + case "netlify": + return { + target: "netlify", + label: "Netlify", + category: "static-site", + buildCommand, + outputDirectory: "dist", + generatedFiles: ["netlify.toml", ...(plan.tooling.githubActions ? [".github/workflows/deploy.yml"] : [])], + secrets: [ + makeDeploymentVariable("NETLIFY_AUTH_TOKEN", "Netlify token for CI or manual deploy workflows.", { + secret: true, + }), + makeDeploymentVariable("NETLIFY_SITE_ID", "Netlify site identifier used by the generated workflow.", { + secret: true, + }), + ], + environmentVariables: [], + }; + case "render": + return { + target: "render", + label: "Render", + category: plan.frontend?.framework === "react-vite" ? "static-site" : "managed-node", + port, + healthPath, + buildCommand, + startCommand, + outputDirectory: plan.frontend?.framework === "react-vite" ? "dist" : undefined, + generatedFiles: ["render.yaml", ...(plan.tooling.githubActions ? [".github/workflows/deploy.yml"] : [])], + secrets: [ + makeDeploymentVariable( + "RENDER_DEPLOY_HOOK_URL", + "Render deploy hook URL used by the generated GitHub Actions workflow.", + { secret: true }, + ), + ], + environmentVariables: [ + ...(plan.frontend?.framework === "react-vite" + ? [makeDeploymentVariable("NODE_VERSION", "Node.js version used during Render builds.", { + example: "22.12.0", + })] + : [ + makeDeploymentVariable("NODE_VERSION", "Node.js version used by Render for build and runtime parity.", { + example: "22.12.0", + }), + ...(port + ? [makeDeploymentVariable("PORT", "Port expected by the Render web service.", { + example: String(port), + })] + : []), + makeDeploymentVariable("HOST", "Bind address for containerized runtime processes.", { + example: "0.0.0.0", + }), + ]), + ], + }; + case "railway": + return { + target: "railway", + label: "Railway", + category: "managed-node", + port, + healthPath, + buildCommand, + startCommand, + generatedFiles: ["railway.toml", ...(plan.tooling.githubActions ? [".github/workflows/deploy.yml"] : [])], + secrets: [ + makeDeploymentVariable("RAILWAY_TOKEN", "Railway project token used by the generated GitHub Actions workflow.", { + secret: true, + }), + makeDeploymentVariable("RAILWAY_PROJECT_ID", "Railway project identifier for CI deploys.", { + secret: true, + }), + makeDeploymentVariable("RAILWAY_ENVIRONMENT_NAME", "Railway environment name or id used by the CI workflow.", { + secret: true, + }), + makeDeploymentVariable("RAILWAY_SERVICE_NAME", "Railway service name to deploy from CI.", { + secret: true, + }), + ], + environmentVariables: [ + ...(port + ? [makeDeploymentVariable("PORT", "Port expected by the Railway runtime.", { + example: String(port), + })] + : []), + makeDeploymentVariable("HOST", "Bind address for the generated runtime server.", { + example: "0.0.0.0", + }), + ], + }; + case "docker-compose": + return { + target: "docker-compose", + label: "Docker Compose", + category: "container", + port, + healthPath, + buildCommand, + startCommand, + generatedFiles: [ + "docker-compose.yml", + "Dockerfile", + ...(plan.tooling.githubActions ? [".github/workflows/deploy.yml"] : []), + ], + secrets: [], + environmentVariables: [ + ...(port + ? [makeDeploymentVariable("PORT", "Port exposed by the generated container.", { + example: String(port), + })] + : []), + makeDeploymentVariable("NODE_ENV", "Runtime mode for containerized app processes.", { + example: "production", + }), + ], + }; + case "none": + default: + return undefined; + } +} + export function packageManagerScriptInvocation( packageManager: PackageManager, script: string, @@ -344,7 +548,7 @@ export function getSupportedDeploymentTargets( plan.architecture === "simple" && plan.frontend?.framework === "react-vite" ) { - targets.push("vercel", "netlify"); + targets.push("vercel", "netlify", "render"); } if ( @@ -352,7 +556,7 @@ export function getSupportedDeploymentTargets( plan.architecture === "simple" && plan.frontend?.framework === "nextjs" ) { - targets.push("vercel"); + targets.push("vercel", "render", "railway"); } if ( @@ -361,7 +565,7 @@ export function getSupportedDeploymentTargets( plan.backend && ["express", "fastify", "hono"].includes(plan.backend.framework) ) { - targets.push("docker-compose"); + targets.push("docker-compose", "render", "railway"); } return targets; @@ -506,9 +710,49 @@ export function getGenericPrerequisites(plan: ProjectPlan): AdvisoryItem[] { return requiredBeforeRun; } +function selectedBackendCapabilityBaselines(plan: ProjectPlan): string[] { + if (!plan.backend) { + return []; + } + + const baselines: string[] = []; + + if (plan.backend.auth.includes("jwt")) { + baselines.push("JWT auth"); + } + + if (plan.backend.auth.includes("oauth")) { + baselines.push("OAuth"); + } + + if (plan.backend.database !== "none") { + baselines.push(`${toTitleCase(plan.backend.database)} database wiring`); + } + + if (plan.backend.orm !== "none") { + baselines.push(`${toTitleCase(plan.backend.orm)} data layer`); + } + + if (plan.backend.redis) { + baselines.push("Redis integration"); + } + + if (plan.backend.swagger) { + baselines.push("Swagger"); + } + + if (plan.backend.websockets) { + baselines.push("WebSockets"); + } + + return baselines; +} + export function getStackNotes(plan: ProjectPlan): string[] { const notes: string[] = []; const defaultUrl = getDefaultLocalUrl(plan); + const deploymentProfile = getDeploymentProfile(plan); + const backendCapabilityBaselines = selectedBackendCapabilityBaselines(plan); if (plan.intent === "backend-api") { notes.push(`The generated API exposes a health endpoint at ${defaultUrl}.`); @@ -545,9 +789,22 @@ export function getStackNotes(plan: ProjectPlan): string[] { notes.push(`Open ${defaultUrl} after starting the dev server to inspect the generated starter surface.`); } - if (plan.deployment.target !== "none") { + if (deploymentProfile) { notes.push( - `The scaffold includes a ${deploymentTargetLabel(plan.deployment.target)} deployment baseline. Review the generated deployment files before using them in production.`, + `The scaffold includes a ${deploymentProfile.label} deployment baseline. Review the generated deployment files before using them in production.`, + ); + if (deploymentProfile.healthPath) { + notes.push( + `Deployment health checks are expected to pass on ${deploymentProfile.healthPath}. Keep that route stable when you start customizing the app.`, + ); + } + } + + if (backendCapabilityBaselines.length > 0) { + notes.push( + `Selected backend capabilities are starter baselines, not full implementations: ${joinSentence( + backendCapabilityBaselines, + )}. Finish the actual modules, providers, configuration, and runtime integration before treating them as production-ready.`, ); } @@ -556,6 +813,7 @@ export function getStackNotes(plan: ProjectPlan): string[] { export function buildTemplateGuidance(plan: ProjectPlan): PostCreateGuidance { const scripts = getPrimaryScriptNames(plan); + const deploymentProfile = getDeploymentProfile(plan); const nextCommands = [ packageManagerInstallCommand(plan.packageManager), scripts.dev ? packageManagerRunCommand(plan.packageManager, scripts.dev) : undefined, @@ -600,6 +858,39 @@ export function buildTemplateGuidance(plan: ProjectPlan): PostCreateGuidance { }); } + if (plan.deployment.target === "render") { + recommended.push({ + title: "Configure Render deploy access before using the deployment workflow", + detail: "Set `RENDER_DEPLOY_HOOK_URL` before triggering the generated Render workflow, then validate the generated `render.yaml` against your Render workspace settings.", + }); + } + + if (plan.deployment.target === "railway") { + recommended.push({ + title: "Configure Railway project identifiers before using the deployment workflow", + detail: "Set `RAILWAY_TOKEN`, `RAILWAY_PROJECT_ID`, `RAILWAY_ENVIRONMENT_NAME`, and `RAILWAY_SERVICE_NAME` before triggering the generated Railway workflow.", + }); + } + + if (deploymentProfile?.environmentVariables.length) { + recommended.push({ + title: "Review deployment environment variables before first deploy", + detail: `The generated ${deploymentProfile.label} baseline expects ${joinSentence( + deploymentProfile.environmentVariables.map((variable) => `\`${variable.name}\``), + )}. Mirror those values in your provider dashboard before treating the deployment config as production-ready.`, + }); + } + + const backendCapabilityBaselines = selectedBackendCapabilityBaselines(plan); + if (backendCapabilityBaselines.length > 0) { + recommended.push({ + title: "Finish backend capability baselines before shipping", + detail: `This scaffold adds starter wiring for ${joinSentence( + backendCapabilityBaselines, + )}, but you still need to implement the real modules, configuration, guards, adapters, persistence, and integration paths for your application.`, + }); + } + return { nextCommands, requiredBeforeRun: getGenericPrerequisites(plan), diff --git a/src/runtime-matrix.ts b/src/runtime-matrix.ts index c92e73b..09e46ed 100644 --- a/src/runtime-matrix.ts +++ b/src/runtime-matrix.ts @@ -44,6 +44,7 @@ type ScenarioExecutionContext = { plan: ProjectPlan; targetDir: string; port: number; + environment: EnvironmentInfo; }; type ScenarioResult = { @@ -69,6 +70,7 @@ type StartedProcess = { const BASE_PORT = 4600; const PROCESS_LOG_LIMIT = 80; +const PNPM_RUNTIME_FALLBACK_VERSION = "9"; function baseCliOptions(): CliOptions { return { @@ -170,15 +172,59 @@ function parseArgs(argv: string[]): ParsedArgs { return parsed; } -function installCommandForPackageManager(packageManager: ProjectPlan["packageManager"]): { +function runtimePackageManagerAvailable( + environment: EnvironmentInfo, + packageManager: ProjectPlan["packageManager"], +): boolean { + if (packageManager === "pnpm") { + return environment.packageManagers.pnpm.installed || environment.packageManagers.npm.installed; + } + + return canUsePackageManager(environment, packageManager); +} + +function runtimePackageManagerInvocation( + environment: EnvironmentInfo, + packageManager: ProjectPlan["packageManager"], + args: string[], +): { command: string; args: string[]; } { + if (packageManager === "pnpm" && !environment.packageManagers.pnpm.installed) { + return { + command: "npx", + args: ["--yes", `pnpm@${PNPM_RUNTIME_FALLBACK_VERSION}`, ...args], + }; + } + switch (packageManager) { case "pnpm": - return { command: "pnpm", args: ["install"] }; + return { command: "pnpm", args }; case "yarn": - return { command: "yarn", args: ["install"] }; + return { command: "yarn", args }; + case "bun": + return { command: "bun", args }; + case "npm": + default: + return { command: "npm", args }; + } +} + +export function installCommandForPackageManager( + environment: EnvironmentInfo, + packageManager: ProjectPlan["packageManager"], +): { + command: string; + args: string[]; +} { + switch (packageManager) { + case "pnpm": + return runtimePackageManagerInvocation(environment, "pnpm", ["install"]); + case "yarn": + return environment.packageManagers.yarn.installed + ? { command: "yarn", args: ["install"] } + : { command: "corepack", args: ["yarn", "install"] }; case "bun": return { command: "bun", args: ["install"] }; case "npm": @@ -187,6 +233,23 @@ function installCommandForPackageManager(packageManager: ProjectPlan["packageMan } } +export function runtimeScriptInvocation( + environment: EnvironmentInfo, + packageManager: ProjectPlan["packageManager"], + script: string, + extraArgs: string[] = [], +): { command: string; args: string[] } { + if (packageManager === "pnpm") { + return runtimePackageManagerInvocation(environment, "pnpm", [ + "run", + script, + ...(extraArgs.length > 0 ? ["--", ...extraArgs] : []), + ]); + } + + return packageManagerScriptInvocation(packageManager, script, extraArgs); +} + function lastLogs(logs: string[]): string { return logs.slice(-PROCESS_LOG_LIMIT).join(""); } @@ -522,7 +585,8 @@ async function verifyPreviewRuntime( env?: Record; }, ): Promise { - const invocation = packageManagerScriptInvocation( + const invocation = runtimeScriptInvocation( + context.environment, context.plan.packageManager, previewCommand.script, previewCommand.extraArgs, @@ -549,7 +613,8 @@ async function verifyScriptRuntime( targets: Array<{ url: string }>, ): Promise { const cwd = command.cwd ? join(context.targetDir, command.cwd) : context.targetDir; - const invocation = packageManagerScriptInvocation( + const invocation = runtimeScriptInvocation( + context.environment, context.plan.packageManager, command.script, command.extraArgs, @@ -578,7 +643,8 @@ async function verifyApiRuntime( command: { cwd?: string; script: string; extraArgs?: string[]; path: string }, ): Promise { const cwd = command.cwd ? join(context.targetDir, command.cwd) : context.targetDir; - const invocation = packageManagerScriptInvocation( + const invocation = runtimeScriptInvocation( + context.environment, context.plan.packageManager, command.script, command.extraArgs, @@ -616,6 +682,26 @@ async function verifyApiRuntime( } } +async function runScenarioScript( + context: ScenarioExecutionContext, + command: { + cwd?: string; + script: string; + extraArgs?: string[]; + env?: Record; + }, +): Promise { + const cwd = command.cwd ? join(context.targetDir, command.cwd) : context.targetDir; + const invocation = runtimeScriptInvocation( + context.environment, + context.plan.packageManager, + command.script, + command.extraArgs, + ); + + await runCommand(invocation.command, invocation.args, cwd, command.env); +} + async function verifyCliJson(context: ScenarioExecutionContext): Promise { const output = await new Promise((resolve, reject) => { const child = spawn("node", ["dist/src/index.js", "--json"], { @@ -727,6 +813,36 @@ export const runtimeScenarios: RuntimeScenario[] = [ ); }, }, + { + name: "frontend-nextjs-railway", + description: "Frontend app runtime on Next.js with Railway baseline", + intent: "frontend-app", + frontendFramework: "nextjs", + mode: "http", + configure(plan) { + plan.frontend = { + framework: "nextjs", + rendering: "ssr", + styling: "vanilla-css", + uiLibrary: "none", + state: "none", + dataFetching: "native-fetch", + }; + plan.deployment.target = "railway"; + plan.tooling.githubActions = true; + }, + async runVerification(context) { + await readFile(join(context.targetDir, "railway.toml"), "utf8"); + await readFile(join(context.targetDir, ".github", "workflows", "deploy.yml"), "utf8"); + return verifyPreviewRuntime( + context, + { + script: "start", + extraArgs: ["--port", String(context.port)], + }, + ); + }, + }, { name: "frontend-react-vite-netlify", description: "Frontend app runtime on React, Vite, and Netlify baseline", @@ -980,6 +1096,35 @@ export const runtimeScenarios: RuntimeScenario[] = [ }); }, }, + { + name: "backend-fastify-render", + description: "Backend API runtime on Fastify with Render baseline", + intent: "backend-api", + backendFramework: "fastify", + mode: "http", + configure(plan) { + plan.backend = { + framework: "fastify", + language: "typescript", + auth: [], + orm: "none", + database: "none", + redis: false, + swagger: false, + websockets: false, + }; + plan.deployment.target = "render"; + plan.tooling.githubActions = true; + }, + async runVerification(context) { + await readFile(join(context.targetDir, "render.yaml"), "utf8"); + await readFile(join(context.targetDir, ".github", "workflows", "deploy.yml"), "utf8"); + return verifyApiRuntime(context, { + script: "start", + path: "/health", + }); + }, + }, { name: "backend-hono", description: "Backend API runtime on Hono", @@ -1040,7 +1185,7 @@ export const runtimeScenarios: RuntimeScenario[] = [ plan.backend = { framework: "nestjs", language: "typescript", - adapter: "express", + adapter: "fastify", auth: [], orm: "none", database: "none", @@ -1048,6 +1193,12 @@ export const runtimeScenarios: RuntimeScenario[] = [ swagger: false, websockets: false, }; + plan.testing = { + enabled: true, + runner: "jest", + environment: "node", + includeExampleTests: true, + }; }, runVerification(context) { return verifyApiRuntime(context, { @@ -1056,6 +1207,47 @@ export const runtimeScenarios: RuntimeScenario[] = [ }); }, }, + { + name: "backend-nestjs-enterprise-pnpm", + description: "Backend API runtime on NestJS Fastify with pnpm and enterprise capabilities", + intent: "backend-api", + backendFramework: "nestjs", + mode: "http", + configure(plan) { + plan.packageManager = "pnpm"; + plan.templateTier = "enterprise"; + plan.tooling.docker = true; + plan.tooling.githubActions = true; + plan.testing = { + enabled: true, + runner: "jest", + environment: "node", + includeExampleTests: true, + }; + plan.backend = { + framework: "nestjs", + language: "typescript", + adapter: "fastify", + auth: ["jwt", "oauth"], + orm: "drizzle", + database: "postgresql", + redis: true, + swagger: true, + websockets: true, + }; + }, + async runVerification(context) { + await readFile(join(context.targetDir, "src", "db", "schema.ts"), "utf8"); + await readFile(join(context.targetDir, "jest.config.cjs"), "utf8"); + await readFile(join(context.targetDir, "Dockerfile"), "utf8"); + await readFile(join(context.targetDir, ".github", "workflows", "ci.yml"), "utf8"); + await runScenarioScript(context, { script: "test" }); + await verifyApiRuntime(context, { + script: "start", + path: "/health", + }); + }, + }, { name: "fullstack-nextjs", description: "Fullstack runtime on Next.js", @@ -1171,6 +1363,34 @@ export const runtimeScenarios: RuntimeScenario[] = [ return verifyExtensionArtifacts(context); }, }, + { + name: "chrome-extension-react-jest-pnpm", + description: "Chrome extension artifact verification on React with pnpm and Jest", + intent: "chrome-extension", + extensionFlavor: "react", + mode: "artifacts", + configure(plan) { + plan.packageManager = "pnpm"; + plan.extension = { + flavor: "react", + includesBackground: true, + includesContent: true, + includesPopup: true, + manifestVersion: "v3", + }; + plan.testing = { + enabled: true, + runner: "jest", + environment: "jsdom", + includeExampleTests: true, + }; + }, + async runVerification(context) { + await readFile(join(context.targetDir, "jest.config.cjs"), "utf8"); + await runScenarioScript(context, { script: "test" }); + await verifyExtensionArtifacts(context); + }, + }, { name: "chrome-extension-vanilla-ts", description: "Chrome extension artifact verification on vanilla TypeScript", @@ -1252,7 +1472,7 @@ async function runScenario( }; } - if (!canUsePackageManager(environment, plan.packageManager)) { + if (!runtimePackageManagerAvailable(environment, plan.packageManager)) { console.log( `[${scenario.name}] skipped because ${plan.packageManager} is not installed in the current environment.`, ); @@ -1272,11 +1492,11 @@ async function runScenario( try { console.log(`[${scenario.name}] installing dependencies`); - const installInvocation = installCommandForPackageManager(plan.packageManager); + const installInvocation = installCommandForPackageManager(environment, plan.packageManager); await runCommand(installInvocation.command, installInvocation.args, targetDir); console.log(`[${scenario.name}] building scaffold`); - const buildInvocation = packageManagerScriptInvocation(plan.packageManager, "build"); + const buildInvocation = runtimeScriptInvocation(environment, plan.packageManager, "build"); await runCommand(buildInvocation.command, buildInvocation.args, targetDir); console.log(`[${scenario.name}] verifying ${scenario.mode}`); @@ -1285,6 +1505,7 @@ async function runScenario( plan, targetDir, port: BASE_PORT + scenarioIndex, + environment, }); } catch (error) { if (!keepFixtures) { diff --git a/src/templates.ts b/src/templates.ts index 471bbbc..d96c813 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -11,6 +11,7 @@ import type { import { buildTemplateGuidance, deploymentTargetLabel, + getDeploymentProfile, getDefaultLocalUrl, packageManagerCiInstallCommand, packageManagerInstallCommand, @@ -530,6 +531,7 @@ function licenseText(license: ProjectPlan["metadata"]["license"]): string { } function architectureDoc(plan: ProjectPlan): string { + const deploymentProfile = getDeploymentProfile(plan); const stack = [ plan.frontend?.framework && `Frontend: ${toTitleCase(plan.frontend.framework)}`, plan.backend?.framework && `Backend: ${toTitleCase(plan.backend.framework)}`, @@ -547,6 +549,20 @@ function architectureDoc(plan: ProjectPlan): string { `- Package manager: ${plan.packageManager}`, `- Node strategy: ${plan.nodeStrategy}${plan.customNodeVersion ? ` (${plan.customNodeVersion})` : ""}`, `- Deployment target: ${deploymentTargetLabel(plan.deployment.target)}`, + ...(deploymentProfile + ? [ + `- Deployment category: ${toTitleCase(deploymentProfile.category)}`, + deploymentProfile.buildCommand + ? `- Deployment build command: ${deploymentProfile.buildCommand}` + : undefined, + deploymentProfile.startCommand + ? `- Deployment start command: ${deploymentProfile.startCommand}` + : undefined, + deploymentProfile.healthPath + ? `- Deployment health path: ${deploymentProfile.healthPath}` + : undefined, + ].filter(Boolean) + : []), stack.length > 0 ? `- Stack: ${joinSentence(stack)}` : "- Stack: metadata-first blueprint", plan.frontend?.state !== undefined ? `- Frontend state: ${toTitleCase(plan.frontend.state)}` : undefined, plan.frontend?.dataFetching !== undefined @@ -573,6 +589,7 @@ function architectureDoc(plan: ProjectPlan): string { function readme(plan: ProjectPlan): string { const guidance = buildTemplateGuidance(plan); + const deploymentProfile = getDeploymentProfile(plan); const installCommand = packageManagerInstallCommand(plan.packageManager); const runtimeCommands = guidance.nextCommands.filter((command) => command !== installCommand); const structure = @@ -621,6 +638,40 @@ function readme(plan: ProjectPlan): string { `- ${item.title}: ${item.detail}`, ...(item.command ? [` Command: \`${item.command}\``] : []), ]), + ...(deploymentProfile + ? [ + "", + "## Deployment Baseline", + `- Provider: ${deploymentProfile.label}`, + `- Category: ${toTitleCase(deploymentProfile.category)}`, + ...(deploymentProfile.buildCommand + ? [`- Build command: \`${deploymentProfile.buildCommand}\``] + : []), + ...(deploymentProfile.startCommand + ? [`- Start command: \`${deploymentProfile.startCommand}\``] + : []), + ...(deploymentProfile.outputDirectory + ? [`- Output directory: \`${deploymentProfile.outputDirectory}\``] + : []), + ...(deploymentProfile.healthPath + ? [`- Health path: \`${deploymentProfile.healthPath}\``] + : []), + ...(deploymentProfile.environmentVariables.length > 0 + ? [ + `- Environment variables: ${joinSentence( + deploymentProfile.environmentVariables.map((variable) => `\`${variable.name}\``), + )}`, + ] + : []), + ...(deploymentProfile.secrets.length > 0 + ? [ + `- Workflow secrets: ${joinSentence( + deploymentProfile.secrets.map((variable) => `\`${variable.name}\``), + )}`, + ] + : []), + ] + : []), "", "## Common Commands", "```bash", @@ -663,6 +714,8 @@ function readme(plan: ProjectPlan): string { "- `AGENTS.md` and optional tool-specific directories contain AI rules.", ...(plan.deployment.target === "vercel" ? ["- `vercel.json` captures the generated Vercel deployment baseline."] : []), ...(plan.deployment.target === "netlify" ? ["- `netlify.toml` captures the generated Netlify deployment baseline."] : []), + ...(plan.deployment.target === "render" ? ["- `render.yaml` captures the generated Render Blueprint baseline."] : []), + ...(plan.deployment.target === "railway" ? ["- `railway.toml` captures the generated Railway config-as-code baseline."] : []), ...(plan.deployment.target === "docker-compose" ? ["- `docker-compose.yml` defines the generated local or self-hosted Docker Compose deployment baseline."] : []), @@ -691,6 +744,7 @@ function readme(plan: ProjectPlan): string { function gettingStartedDoc(plan: ProjectPlan): string { const guidance = buildTemplateGuidance(plan); + const deploymentProfile = getDeploymentProfile(plan); const installCommand = packageManagerInstallCommand(plan.packageManager); const runtimeCommands = guidance.nextCommands.filter((command) => command !== installCommand); @@ -717,6 +771,36 @@ function gettingStartedDoc(plan: ProjectPlan): string { `- ${item.title}: ${item.detail}`, ...(item.command ? [` Command: \`${item.command}\``] : []), ]), + ...(deploymentProfile + ? [ + "", + "## Deployment Checklist", + `- Provider: ${deploymentProfile.label}`, + ...(deploymentProfile.buildCommand + ? [`- Build command: \`${deploymentProfile.buildCommand}\``] + : []), + ...(deploymentProfile.startCommand + ? [`- Start command: \`${deploymentProfile.startCommand}\``] + : []), + ...(deploymentProfile.healthPath + ? [`- Health path: \`${deploymentProfile.healthPath}\``] + : []), + ...(deploymentProfile.environmentVariables.length > 0 + ? [ + `- Configure these environment variables before the first deploy: ${joinSentence( + deploymentProfile.environmentVariables.map((variable) => `\`${variable.name}\``), + )}`, + ] + : []), + ...(deploymentProfile.secrets.length > 0 + ? [ + `- Configure these provider or CI secrets before the first deploy: ${joinSentence( + deploymentProfile.secrets.map((variable) => `\`${variable.name}\``), + )}`, + ] + : []), + ] + : []), "", "## Daily Commands", "```bash", @@ -756,6 +840,7 @@ function gettingStartedDoc(plan: ProjectPlan): string { } function envExample(plan: ProjectPlan): string { + const deploymentProfile = getDeploymentProfile(plan); const lines = [ `APP_NAME=${toConstantCase(plan.projectName)}`, "NODE_ENV=development", @@ -781,6 +866,19 @@ function envExample(plan: ProjectPlan): string { lines.push("PUBLIC_API_URL=http://localhost:3001"); } + for (const variable of deploymentProfile?.environmentVariables ?? []) { + if (!variable.example) { + continue; + } + + const keyPrefix = `${variable.name}=`; + if (lines.some((line) => line.startsWith(keyPrefix))) { + continue; + } + + lines.push(`${variable.name}=${variable.example}`); + } + return `${lines.join("\n")}\n`; } @@ -1371,7 +1469,7 @@ function singlePackageScripts(plan: ProjectPlan): Record { addRecord(scripts, { dev: "next dev", build: "next build", - start: "next start", + start: "next start --hostname 0.0.0.0", }); break; case "astro": @@ -1430,7 +1528,7 @@ function singlePackageScripts(plan: ProjectPlan): Record { addRecord(scripts, { dev: "next dev", build: "next build", - start: "next start", + start: "next start --hostname 0.0.0.0", }); } else { addRecord(scripts, { @@ -2830,9 +2928,10 @@ function backendServerSource(plan: ProjectPlan): string { "app.get(\"/\", (_req, res) => res.json(projectInfo));", "app.get(\"/health\", (_req, res) => res.json({ ok: true, status: \"healthy\", ...projectInfo }));", "", + "const host = process.env.HOST ?? \"0.0.0.0\";", "const port = Number(process.env.PORT ?? 3001);", - "app.listen(port, () => {", - " console.log(`API listening on http://localhost:${port}`);", + "app.listen(port, host, () => {", + " console.log(`API listening on http://${host === \"0.0.0.0\" ? \"localhost\" : host}:${port}`);", "});", "", ].join("\n"); @@ -2846,8 +2945,9 @@ function backendServerSource(plan: ProjectPlan): string { "app.get(\"/\", async () => projectInfo);", "app.get(\"/health\", async () => ({ ok: true, status: \"healthy\", ...projectInfo }));", "", + "const host = process.env.HOST ?? \"0.0.0.0\";", "const port = Number(process.env.PORT ?? 3001);", - "app.listen({ port });", + "app.listen({ port, host });", "", ].join("\n"); case "koa": @@ -2866,14 +2966,20 @@ function backendServerSource(plan: ProjectPlan): string { " ctx.body = projectInfo;", "});", "", + "const host = process.env.HOST ?? \"0.0.0.0\";", "const port = Number(process.env.PORT ?? 3001);", - "app.listen(port);", + "app.listen(port, host);", "", ].join("\n"); case "nestjs": return [ "import { NestFactory } from \"@nestjs/core\";", "import { Module, Controller, Get } from \"@nestjs/common\";", + ...(plan.backend?.adapter === "fastify" + ? [ + 'import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify";', + ] + : []), "", `const projectInfo = ${projectInfo};`, "", @@ -2894,8 +3000,16 @@ function backendServerSource(plan: ProjectPlan): string { "class AppModule {}", "", "async function bootstrap() {", - " const app = await NestFactory.create(AppModule);", - " await app.listen(Number(process.env.PORT ?? 3001));", + ...(plan.backend?.adapter === "fastify" + ? [ + " const app = await NestFactory.create(", + " AppModule,", + " new FastifyAdapter(),", + " );", + ] + : [" const app = await NestFactory.create(AppModule);"]), + " const host = process.env.HOST ?? \"0.0.0.0\";", + " await app.listen(Number(process.env.PORT ?? 3001), host);", "}", "", "bootstrap();", @@ -2913,8 +3027,10 @@ function backendServerSource(plan: ProjectPlan): string { "app.get(\"/\", (c) => c.json(projectInfo));", "app.get(\"/health\", (c) => c.json({ ok: true, status: \"healthy\", ...projectInfo }));", "", + "const host = process.env.HOST ?? \"0.0.0.0\";", "serve({", " fetch: app.fetch,", + " hostname: host,", " port: Number(process.env.PORT ?? 3001),", "});", "", @@ -3334,6 +3450,74 @@ function netlifyConfig(plan: ProjectPlan): string { ].join("\n"); } +function renderBlueprint(plan: ProjectPlan): string { + const profile = getDeploymentProfile(plan); + const serviceName = plan.projectName.replace(/[^a-z0-9-]/gi, "-").toLowerCase(); + + if (!profile || plan.deployment.target !== "render") { + return ""; + } + + if (profile.category === "static-site") { + return [ + "services:", + " - type: web", + ` name: ${serviceName}`, + " runtime: static", + ` buildCommand: ${profile.buildCommand}`, + ` staticPublishPath: ${profile.outputDirectory ?? "dist"}`, + " envVars:", + " - key: NODE_VERSION", + " value: 22.12.0", + "", + ].join("\n"); + } + + return [ + "services:", + " - type: web", + ` name: ${serviceName}`, + " runtime: node", + ` buildCommand: ${profile.buildCommand}`, + ` startCommand: ${profile.startCommand}`, + ...(profile.healthPath ? [` healthCheckPath: ${profile.healthPath}`] : []), + " envVars:", + " - key: NODE_VERSION", + " value: 22.12.0", + ...(profile.port + ? [ + " - key: PORT", + ` value: ${profile.port}`, + ] + : []), + " - key: HOST", + " value: 0.0.0.0", + "", + ].join("\n"); +} + +function railwayConfig(plan: ProjectPlan): string { + const profile = getDeploymentProfile(plan); + + if (!profile || plan.deployment.target !== "railway") { + return ""; + } + + return [ + "[build]", + 'builder = "RAILPACK"', + ...(profile.buildCommand ? [`buildCommand = "${profile.buildCommand}"`] : []), + "", + "[deploy]", + ...(profile.startCommand ? [`startCommand = "${profile.startCommand}"`] : []), + ...(profile.healthPath ? [`healthcheckPath = "${profile.healthPath}"`] : []), + "healthcheckTimeout = 100", + 'restartPolicyType = "ON_FAILURE"', + "restartPolicyMaxRetries = 10", + "", + ].join("\n"); +} + function dockerComposeFile(plan: ProjectPlan): string { const serviceName = plan.projectName.replace(/[^a-z0-9-]/gi, "-").toLowerCase(); const internalPort = plan.intent === "backend-api" ? 3001 : 3000; @@ -3405,6 +3589,28 @@ function deploymentWorkflow(plan: ProjectPlan): string | undefined { ); } + if (plan.deployment.target === "render") { + lines.push( + ` - run: ${packageManagerRunCommand(plan.packageManager, "build")}`, + " - run: curl --fail-with-body -X POST \"$RENDER_DEPLOY_HOOK_URL\"", + " env:", + " RENDER_DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_URL }}", + ); + } + + if (plan.deployment.target === "railway") { + lines.push( + " - run: npm install --global @railway/cli", + ` - run: ${packageManagerRunCommand(plan.packageManager, "build")}`, + " - run: railway up --ci --project \"$RAILWAY_PROJECT_ID\" --environment \"$RAILWAY_ENVIRONMENT_NAME\" --service \"$RAILWAY_SERVICE_NAME\"", + " env:", + " RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}", + " RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}", + " RAILWAY_ENVIRONMENT_NAME: ${{ secrets.RAILWAY_ENVIRONMENT_NAME }}", + " RAILWAY_SERVICE_NAME: ${{ secrets.RAILWAY_SERVICE_NAME }}", + ); + } + if (plan.deployment.target === "docker-compose") { lines.push( " - run: docker compose config", @@ -3427,6 +3633,14 @@ function deploymentFiles(plan: ProjectPlan): GeneratedFile[] { files.push(makeManagedFile("netlify.toml", netlifyConfig(plan))); } + if (plan.deployment.target === "render") { + files.push(makeManagedFile("render.yaml", renderBlueprint(plan))); + } + + if (plan.deployment.target === "railway") { + files.push(makeManagedFile("railway.toml", railwayConfig(plan))); + } + if (plan.deployment.target === "docker-compose") { files.push(makeManagedFile("docker-compose.yml", dockerComposeFile(plan))); } @@ -3621,7 +3835,7 @@ function testingFiles(plan: ProjectPlan): GeneratedFile[] { ? [ ' extensionsToTreatAsEsm: [".ts", ".tsx"],', " transform: {", - ' "^.+\\\\.(ts|tsx)$": ["ts-jest", { useESM: true, tsconfig: "tsconfig.json" }],', + ' "^.+\\\\.(ts|tsx)$": ["ts-jest", { useESM: true, tsconfig: "tsconfig.json", diagnostics: { ignoreCodes: [151002, 5107] } }],', " },", ' moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],', ] @@ -3635,6 +3849,7 @@ function testingFiles(plan: ProjectPlan): GeneratedFile[] { makeFile( `src/__tests__/starter.test.${testExtension}`, [ + ...(usesTypeScript(plan) ? ['/// ', ""] : []), "describe(\"starter test\", () => {", " it(\"keeps the scaffold wired\", () => {", " expect(true).toBe(true);", diff --git a/src/types.ts b/src/types.ts index 9d800db..44be0ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -81,7 +81,13 @@ export type RuleCategory = export type StrictnessLevel = "low" | "moderate" | "strict"; export type TestRunner = "none" | "vitest" | "jest" | "playwright" | "cypress"; export type TestEnvironment = "none" | "node" | "jsdom" | "happy-dom" | "browser-e2e"; -export type DeploymentTarget = "none" | "vercel" | "netlify" | "docker-compose"; +export type DeploymentTarget = + | "none" + | "vercel" + | "netlify" + | "docker-compose" + | "render" + | "railway"; export type LicenseChoice = | "MIT" | "Apache-2.0" @@ -207,6 +213,30 @@ export interface MetadataConfig { export interface DeploymentConfig { target: DeploymentTarget; + healthPath?: string; + port?: number; +} + +export interface DeploymentVariable { + name: string; + description: string; + example?: string; + secret?: boolean; +} + +export interface DeploymentProfile { + target: Exclude; + label: string; + category: "static-site" | "managed-node" | "container"; + port?: number; + healthPath?: string; + installCommand?: string; + buildCommand?: string; + startCommand?: string; + outputDirectory?: string; + generatedFiles: string[]; + secrets: DeploymentVariable[]; + environmentVariables: DeploymentVariable[]; } export interface ProjectPlan { diff --git a/test/decision.test.ts b/test/decision.test.ts index f9d1b4d..7ce4c6b 100644 --- a/test/decision.test.ts +++ b/test/decision.test.ts @@ -333,6 +333,7 @@ test("deployment targets are only offered for verified stack pairs and normalize "none", "vercel", "netlify", + "render", ]); const unsupportedPlan = buildDefaultPlan(environment, cliOptions); @@ -371,6 +372,29 @@ test("deployment targets are only offered for verified stack pairs and normalize const backendResult = normalizeProjectPlan(backendPlan, environment); assert.equal(backendResult.plan.deployment.target, "docker-compose"); assert.equal(backendResult.plan.tooling.docker, true); + assert.deepEqual(getSupportedDeploymentTargets(backendPlan), [ + "none", + "docker-compose", + "render", + "railway", + ]); + + const nextPlan = buildDefaultPlan(environment, cliOptions); + nextPlan.frontend = { + framework: "nextjs", + rendering: "ssr", + styling: "vanilla-css", + uiLibrary: "none", + state: "none", + dataFetching: "native-fetch", + }; + + assert.deepEqual(getSupportedDeploymentTargets(nextPlan), [ + "none", + "vercel", + "render", + "railway", + ]); }); test("remix rendering stays on supported modes", () => { diff --git a/test/deployment.test.ts b/test/deployment.test.ts new file mode 100644 index 0000000..71db332 --- /dev/null +++ b/test/deployment.test.ts @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getDeploymentProfile } from "../src/guidance.js"; +import { buildDefaultPlan, applyIntentDefaults } from "../src/engines/prompts.js"; +import type { CliOptions, EnvironmentInfo } from "../src/types.js"; + +const environment: EnvironmentInfo = { + platform: "darwin", + arch: "arm64", + nodeVersion: "v22.12.0", + recommendedPackageManager: "pnpm", + packageManagers: { + npm: { installed: true, version: "10.5.1", path: "/usr/bin/npm" }, + pnpm: { installed: true, version: "9.0.0", path: "/usr/local/bin/pnpm" }, + yarn: { installed: true, version: "4.1.0", path: "/usr/local/bin/yarn" }, + bun: { installed: true, version: "1.3.11", path: "/usr/local/bin/bun" }, + }, +}; + +const cliOptions: CliOptions = { + command: "init", + resume: false, + skipInstall: true, + yes: true, + outputDir: "/tmp/devforge-deployment-profile-test", + projectName: "devforge-deployment-profile-test", +}; + +test("render profile exposes managed-node deployment metadata for backend APIs", () => { + const plan = buildDefaultPlan(environment, cliOptions); + plan.intent = "backend-api"; + applyIntentDefaults(plan); + plan.backend = { + framework: "fastify", + language: "typescript", + auth: [], + orm: "none", + database: "none", + redis: false, + swagger: true, + websockets: false, + }; + plan.deployment.target = "render"; + + const profile = getDeploymentProfile(plan); + + assert.ok(profile); + assert.equal(profile.target, "render"); + assert.equal(profile.category, "managed-node"); + assert.equal(profile.port, 3001); + assert.equal(profile.healthPath, "/health"); + assert.match(profile.startCommand ?? "", /pnpm run start/); + assert.deepEqual( + profile.secrets.map((variable) => variable.name), + ["RENDER_DEPLOY_HOOK_URL"], + ); +}); + +test("railway profile exposes deploy-time secrets and generated files", () => { + const plan = buildDefaultPlan(environment, cliOptions); + plan.frontend = { + framework: "nextjs", + rendering: "ssr", + styling: "vanilla-css", + uiLibrary: "none", + state: "none", + dataFetching: "native-fetch", + }; + plan.deployment.target = "railway"; + + const profile = getDeploymentProfile(plan); + + assert.ok(profile); + assert.equal(profile.target, "railway"); + assert.deepEqual(profile.generatedFiles, ["railway.toml", ".github/workflows/deploy.yml"]); + assert.equal(profile.port, 3000); + assert.equal(profile.healthPath, "/"); + assert.deepEqual( + profile.secrets.map((variable) => variable.name), + [ + "RAILWAY_TOKEN", + "RAILWAY_PROJECT_ID", + "RAILWAY_ENVIRONMENT_NAME", + "RAILWAY_SERVICE_NAME", + ], + ); +}); diff --git a/test/generator.test.ts b/test/generator.test.ts index e789d09..c6e82dc 100644 --- a/test/generator.test.ts +++ b/test/generator.test.ts @@ -450,6 +450,10 @@ test("jest scaffolds emit runnable cjs config for TypeScript projects", () => { assert.match(jestConfigFile.content, /module\.exports = config/); assert.match(jestConfigFile.content, /ts-jest/); assert.match(jestConfigFile.content, /extensionsToTreatAsEsm/); + assert.match(jestConfigFile.content, /ignoreCodes: \[151002, 5107\]/); + const exampleTest = files.find((file) => file.path === "src/__tests__/starter.test.ts"); + assert.ok(exampleTest); + assert.match(exampleTest.content, /reference types="jest"/); }); test("supported deployment targets generate target-specific files and docs", () => { @@ -474,6 +478,29 @@ test("supported deployment targets generate target-specific files and docs", () assert.match(netlifyFile.content, /publish = "dist"/); assert.match(reactReadme.content, /Deployment target: Netlify/); + const renderFrontendPlan = buildDefaultPlan(environment, cliOptions); + renderFrontendPlan.intent = "frontend-app"; + renderFrontendPlan.frontend = { + framework: "react-vite", + rendering: "client", + styling: "tailwind-css", + uiLibrary: "shadcn-ui", + state: "zustand", + dataFetching: "tanstack-query", + }; + renderFrontendPlan.deployment.target = "render"; + renderFrontendPlan.tooling.githubActions = true; + + const renderFrontendFiles = buildProjectFiles(renderFrontendPlan, environment); + const renderYaml = renderFrontendFiles.find((file) => file.path === "render.yaml"); + const renderWorkflow = renderFrontendFiles.find((file) => file.path === ".github/workflows/deploy.yml"); + + assert.ok(renderYaml); + assert.ok(renderWorkflow); + assert.match(renderYaml.content, /runtime: static/); + assert.match(renderYaml.content, /staticPublishPath: dist/); + assert.match(renderWorkflow.content, /RENDER_DEPLOY_HOOK_URL/); + const nextPlan = buildDefaultPlan(environment, cliOptions); nextPlan.intent = "frontend-app"; nextPlan.frontend = { @@ -494,6 +521,32 @@ test("supported deployment targets generate target-specific files and docs", () assert.ok(deployWorkflow); assert.match(deployWorkflow.content, /vercel deploy --prebuilt --prod/); + const railwayPlan = buildDefaultPlan(environment, cliOptions); + railwayPlan.intent = "frontend-app"; + railwayPlan.frontend = { + framework: "nextjs", + rendering: "ssr", + styling: "vanilla-css", + uiLibrary: "none", + state: "none", + dataFetching: "native-fetch", + }; + railwayPlan.deployment.target = "railway"; + railwayPlan.tooling.githubActions = true; + + const railwayFiles = buildProjectFiles(railwayPlan, environment); + const railwayConfigFile = railwayFiles.find((file) => file.path === "railway.toml"); + const railwayWorkflow = railwayFiles.find((file) => file.path === ".github/workflows/deploy.yml"); + const railwayPackageJson = railwayFiles.find((file) => file.path === "package.json"); + + assert.ok(railwayConfigFile); + assert.ok(railwayWorkflow); + assert.ok(railwayPackageJson); + assert.match(railwayConfigFile.content, /\[deploy\]/); + assert.match(railwayConfigFile.content, /startCommand = "pnpm run start"/); + assert.match(railwayWorkflow.content, /railway up --ci/); + assert.match(railwayPackageJson.content, /next start --hostname 0\.0\.0\.0/); + const backendPlan = buildDefaultPlan(environment, cliOptions); backendPlan.intent = "backend-api"; applyIntentDefaults(backendPlan); @@ -518,6 +571,33 @@ test("supported deployment targets generate target-specific files and docs", () assert.ok(dockerfile); assert.match(composeFile.content, /3001:3001/); assert.match(dockerfile.content, /CMD \["pnpm","run","start"\]/); + + const renderBackendPlan = buildDefaultPlan(environment, cliOptions); + renderBackendPlan.intent = "backend-api"; + applyIntentDefaults(renderBackendPlan); + renderBackendPlan.backend = { + framework: "fastify", + language: "typescript", + auth: [], + orm: "none", + database: "none", + redis: false, + swagger: true, + websockets: false, + }; + renderBackendPlan.deployment.target = "render"; + renderBackendPlan.tooling.githubActions = true; + + const renderBackendFiles = buildProjectFiles(renderBackendPlan, environment); + const renderBackendYaml = renderBackendFiles.find((file) => file.path === "render.yaml"); + const backendServer = renderBackendFiles.find((file) => file.path === "src/server.ts"); + + assert.ok(renderBackendYaml); + assert.ok(backendServer); + assert.match(renderBackendYaml.content, /runtime: node/); + assert.match(renderBackendYaml.content, /healthCheckPath: \/health/); + assert.match(renderBackendYaml.content, /HOST/); + assert.match(backendServer.content, /const host = process\.env\.HOST \?\? "0\.0\.0\.0"/); }); test("nestjs and javascript backend scaffolds include compatible build settings", () => { @@ -527,7 +607,7 @@ test("nestjs and javascript backend scaffolds include compatible build settings" nestPlan.backend = { framework: "nestjs", language: "typescript", - adapter: "express", + adapter: "fastify", auth: [], orm: "none", database: "none", @@ -538,10 +618,19 @@ test("nestjs and javascript backend scaffolds include compatible build settings" const nestFiles = buildProjectFiles(nestPlan, environment); const nestTsconfig = nestFiles.find((file) => file.path === "tsconfig.json"); + const nestPackageJsonFile = nestFiles.find((file) => file.path === "package.json"); + const nestServerFile = nestFiles.find((file) => file.path === "src/server.ts"); assert.ok(nestTsconfig); + assert.ok(nestPackageJsonFile); + assert.ok(nestServerFile); assert.match(nestTsconfig.content, /"experimentalDecorators": true/); assert.match(nestTsconfig.content, /"emitDecoratorMetadata": true/); + assert.match(nestPackageJsonFile.content, /@nestjs\/platform-fastify/); + assert.doesNotMatch(nestPackageJsonFile.content, /@nestjs\/platform-express/); + assert.match(nestServerFile.content, /FastifyAdapter/); + assert.match(nestServerFile.content, /NestFastifyApplication/); + assert.match(nestServerFile.content, /new FastifyAdapter\(\)/); const jsBackendPlan = buildDefaultPlan(environment, cliOptions); jsBackendPlan.intent = "backend-api"; @@ -567,6 +656,33 @@ test("nestjs and javascript backend scaffolds include compatible build settings" assert.match(jsPackageJson.scripts.build, /does not require compilation/); }); +test("backend capability selections are documented as starter baselines in generated docs", () => { + const plan = buildDefaultPlan(environment, cliOptions); + plan.intent = "backend-api"; + applyIntentDefaults(plan); + plan.backend = { + framework: "nestjs", + language: "typescript", + adapter: "fastify", + auth: ["jwt", "oauth"], + orm: "drizzle", + database: "postgresql", + redis: true, + swagger: true, + websockets: true, + }; + + const files = buildProjectFiles(plan, environment); + const readmeFile = files.find((file) => file.path === "README.md"); + const gettingStartedFile = files.find((file) => file.path === "docs/getting-started.md"); + + assert.ok(readmeFile); + assert.ok(gettingStartedFile); + assert.match(readmeFile.content, /Finish backend capability baselines before shipping/); + assert.match(readmeFile.content, /starter baselines, not full implementations/); + assert.match(gettingStartedFile.content, /Finish backend capability baselines before shipping/); +}); + test("lts and latest node strategies do not emit a version-manager file", () => { const ltsPlan = buildDefaultPlan(environment, cliOptions); const latestPlan = buildDefaultPlan(environment, cliOptions); diff --git a/test/guidance.test.ts b/test/guidance.test.ts index afc2da9..e0ce464 100644 --- a/test/guidance.test.ts +++ b/test/guidance.test.ts @@ -301,3 +301,31 @@ test("template guidance carries Bun and Docker prerequisites into generated docs /Install Docker before using container workflows/i, ); }); + +test("template guidance flags backend capability selections as starter baselines", () => { + const environment = createEnvironment("darwin"); + const plan = buildDefaultPlan(environment, cliOptions); + plan.intent = "backend-api"; + plan.backend = { + framework: "nestjs", + language: "typescript", + adapter: "fastify", + auth: ["jwt", "oauth"], + orm: "drizzle", + database: "postgresql", + redis: true, + swagger: true, + websockets: true, + }; + + const guidance = buildTemplateGuidance(plan); + + assert.match( + guidance.recommended.map((item) => `${item.title} ${item.detail}`).join("\n"), + /Finish backend capability baselines before shipping[\s\S]*JWT auth[\s\S]*OAuth[\s\S]*Postgresql database wiring[\s\S]*Drizzle data layer[\s\S]*Redis integration[\s\S]*Swagger[\s\S]*WebSockets/i, + ); + assert.match( + guidance.stackNotes.join("\n"), + /starter baselines, not full implementations/i, + ); +}); diff --git a/test/runtime-matrix.test.ts b/test/runtime-matrix.test.ts index ec9dfd6..12a12b7 100644 --- a/test/runtime-matrix.test.ts +++ b/test/runtime-matrix.test.ts @@ -3,10 +3,31 @@ import assert from "node:assert/strict"; import { expectedBackendFrameworkCoverage, expectedIntentCoverage, + installCommandForPackageManager, + runtimeScriptInvocation, runtimeScenarioCoverage, runtimeScenarios, } from "../src/runtime-matrix.js"; +const environmentWithoutPnpm = { + platform: "linux", + arch: "x64", + nodeVersion: "v22.12.0", + recommendedPackageManager: "pnpm", + packageManagers: { + npm: { installed: true, version: "10.9.0", path: "/usr/bin/npm" }, + pnpm: { installed: false }, + yarn: { installed: false }, + bun: { installed: false }, + }, + systemTools: { + git: { installed: true, version: "2.45.0", path: "/usr/bin/git" }, + docker: { installed: true, version: "27.0.0", path: "/usr/bin/docker" }, + corepack: { installed: true, version: "0.29.4", path: "/usr/bin/corepack" }, + fnm: { installed: true, version: "1.37.1", path: "/usr/bin/fnm" }, + }, +} as const; + test("runtime matrix covers every primary project intent", () => { const coverage = runtimeScenarioCoverage(); @@ -37,10 +58,36 @@ test("runtime matrix includes deployment-target verification paths", () => { assert.ok( runtimeScenarios.find((scenario) => scenario.name === "frontend-nextjs"), ); + assert.ok( + runtimeScenarios.find((scenario) => scenario.name === "frontend-nextjs-railway"), + ); assert.ok( runtimeScenarios.find((scenario) => scenario.name === "frontend-react-vite-netlify"), ); assert.ok( runtimeScenarios.find((scenario) => scenario.name === "backend-fastify"), ); + assert.ok( + runtimeScenarios.find((scenario) => scenario.name === "backend-fastify-render"), + ); + assert.ok( + runtimeScenarios.find((scenario) => scenario.name === "backend-nestjs-enterprise-pnpm"), + ); + assert.ok( + runtimeScenarios.find((scenario) => scenario.name === "chrome-extension-react-jest-pnpm"), + ); +}); + +test("runtime matrix uses npm-backed pnpm install fallback when pnpm is not installed", () => { + const invocation = installCommandForPackageManager(environmentWithoutPnpm, "pnpm"); + + assert.equal(invocation.command, "npx"); + assert.deepEqual(invocation.args, ["--yes", "pnpm@9", "install"]); +}); + +test("runtime matrix uses npm-backed pnpm script fallback when pnpm is not installed", () => { + const invocation = runtimeScriptInvocation(environmentWithoutPnpm, "pnpm", "test"); + + assert.equal(invocation.command, "npx"); + assert.deepEqual(invocation.args, ["--yes", "pnpm@9", "run", "test"]); }); diff --git a/test/upgrade.test.ts b/test/upgrade.test.ts index f9cc06a..d6ffee3 100644 --- a/test/upgrade.test.ts +++ b/test/upgrade.test.ts @@ -65,6 +65,34 @@ test("upgrade applies deployment-aware managed files from the stored project pla assert.match(deployWorkflow, /vercel deploy --prebuilt --prod/); }); +test("upgrade removes obsolete managed deployment files when the target changes", async () => { + const targetDir = await mkdtemp(join(tmpdir(), "devforge-upgrade-deployment-switch-")); + const plan = buildDefaultPlan(environment, { + ...cliOptions, + outputDir: targetDir, + projectName: "devforge-upgrade-deployment-switch", + }); + plan.targetDir = targetDir; + plan.deployment.target = "vercel"; + plan.tooling.githubActions = true; + + await generateProject(plan, environment); + + const storedPlanPath = join(targetDir, PROJECT_PLAN_PATH); + const storedPlan = JSON.parse(await readFile(storedPlanPath, "utf8")) as ProjectPlan; + storedPlan.deployment.target = "render"; + await writeFile(storedPlanPath, `${JSON.stringify(storedPlan, null, 2)}\n`, "utf8"); + + await applyUpgrade(targetDir, environment, { skipInstall: true }); + + await assert.rejects( + () => readFile(join(targetDir, "vercel.json"), "utf8"), + /ENOENT/, + ); + const renderYaml = await readFile(join(targetDir, "render.yaml"), "utf8"); + assert.match(renderYaml, /runtime: static/); +}); + test("upgrade skips managed files that users have modified away from the generated baseline", async () => { const targetDir = await mkdtemp(join(tmpdir(), "devforge-upgrade-skip-custom-")); const plan = buildDefaultPlan(environment, {