diff --git a/.changeset/canonical-saas-first-success.md b/.changeset/canonical-saas-first-success.md new file mode 100644 index 000000000..e23357aff --- /dev/null +++ b/.changeset/canonical-saas-first-success.md @@ -0,0 +1,5 @@ +--- +"create-croco-app": patch +--- + +Make the public `saas-api` scaffold journey executable through the real CLI contract and verify its generated zero-credential demo smoke. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 297a57ffb..5dd4ecda9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: release_pr_update_pattern='^\.changeset/(pre\.json|[^/]+\.md)$' release_pr_update_ignore_pattern='^\.changeset/README\.md$' publish_candidate_pattern='^packages/[^/]+/(package\.json|CHANGELOG\.md)$|^package\.json$|^pnpm-lock\.yaml$|^pnpm-workspace\.yaml$' - release_gate_maintenance_pattern='^\.github/workflows/release\.yml$|^scripts/(alpha-release-smoke\.mts|certification-policy\.mts|changeset-required-check\.mts|core-coverage-warning-check\.mts|create-croco-app-generated-smoke(-(support|matrix))?\.mts|dependency-audit-policy\.mts|first-success-verify\.mts|normalize-packages\.mjs|package-bin-smoke\.mts|package-entrypoint-smoke\.mts|package-manifest-contracts\.mjs|package-quality-report\.mts|production-ready-check\.mts|provider-certification-check\.mts|public-api-surface\.mts|quick-start-lambda-smoke\.mts|release-docs-check\.mts|release-metadata-check\.mts|release-spine-evidence\.mts|security-allowlist-metadata-check\.mts|spine-promotion-check\.mts)$|^scripts/tests/(alpha-release-smoke|changeset-required-check|core-coverage-warning-check|create-croco-app-generated-smoke|dependency-audit-policy|first-success-verify|normalize-packages|package-bin-smoke|package-entrypoint-smoke|package-quality-report|production-ready-check|provider-certification-check|public-api-surface|release-docs-check|release-metadata-check|release-spine-evidence|release-workflow|security-allowlist-metadata-check|spine-promotion-check)\.spec\.ts$' + release_gate_maintenance_pattern='^\.github/workflows/release\.yml$|^scripts/(alpha-release-smoke\.mts|certification-policy\.mts|changeset-required-check\.mts|core-coverage-warning-check\.mts|create-croco-app-generated-smoke(-(support|matrix))?\.mts|dependency-audit-policy\.mts|first-success-generated-contract\.mts|first-success-verify\.mts|normalize-packages\.mjs|package-bin-smoke\.mts|package-entrypoint-smoke\.mts|package-manifest-contracts\.mjs|package-quality-report\.mts|production-ready-check\.mts|provider-certification-check\.mts|public-api-surface\.mts|quick-start-lambda-smoke\.mts|release-docs-check\.mts|release-metadata-check\.mts|release-spine-evidence\.mts|security-allowlist-metadata-check\.mts|spine-promotion-check\.mts)$|^scripts/tests/(alpha-release-smoke|changeset-required-check|core-coverage-warning-check|create-croco-app-generated-smoke|dependency-audit-policy|first-success-verify|normalize-packages|package-bin-smoke|package-entrypoint-smoke|package-quality-report|production-ready-check|provider-certification-check|public-api-surface|release-docs-check|release-metadata-check|release-spine-evidence|release-workflow|security-allowlist-metadata-check|spine-promotion-check)\.spec\.ts$' changed_files="$(git diff --name-only "$base" HEAD)" should_update_release_pr=false diff --git a/README.md b/README.md index 280ce6290..52cf98fc0 100644 --- a/README.md +++ b/README.md @@ -131,16 +131,16 @@ graph LR ## πŸš€ Quick Start -> Lambda 기반 REST APIλ₯Ό λΉ λ₯΄κ²Œ μ‹œμž‘ν•˜μ„Έμš”. +> μ‹€ν–‰ κ°€λŠ₯ν•œ SaaS REST API 골든 패슀λ₯Ό λΉ λ₯΄κ²Œ μ‹œμž‘ν•˜μ„Έμš”. > > **첫 번째 ν”„λ‘œμ νŠΈ 생성**: > > ```bash -> npx create-croco-app@latest my-project --preset ddd-api --scope @myorg --api graphql --backend-deploy lambda --no-install --no-git -> cd my-project && pnpm install && pnpm dev +> npx create-croco-app@latest my-saas-api --goal saas-api --scope @myorg --no-install --no-git +> cd my-saas-api && pnpm install && pnpm demo:smoke > ``` > -> Generated projects are pnpm workspaces; use `--no-install` if you want to skip the automatic `pnpm install` step. +> `demo:smoke` validates the generated REST contracts, in-memory SaaS flow, and operational smoke without external credentials. > > **Route A (Scaffold)**: [Getting Started Guide](packages/docs/src/content/docs/en/guides/getting-started.mdx)μ—μ„œ scaffoldλΆ€ν„° Auth, Metering, Lambda λ°°ν¬κΉŒμ§€ λ‹¨κ³„λ³„λ‘œ SaaS APIλ₯Ό κ΅¬μΆ•ν•˜μ„Έμš”. > diff --git a/package.json b/package.json index af239d671..4846255fd 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "docs:catalog:check": "node --experimental-strip-types scripts/package-docs-check.mts --check", "docs:catalog:write": "node --experimental-strip-types scripts/package-docs-check.mts --write", "dependency-boundaries:check": "node --experimental-strip-types scripts/package-quality-report.mts --boundary-check-only", - "first-success:verify": "node --experimental-strip-types scripts/first-success-verify.mts", + "first-success:verify": "pnpm build --filter=create-croco-app && node --experimental-strip-types scripts/first-success-verify.mts", "package-quality:report": "node --experimental-strip-types scripts/package-quality-report.mts", "provider-certification:check": "node --experimental-strip-types scripts/provider-certification-check.mts", "production-ready:check": "node --experimental-strip-types scripts/production-ready-check.mts", diff --git a/packages/create-croco-app/README.md b/packages/create-croco-app/README.md index 6fc4d6f88..19fca6847 100644 --- a/packages/create-croco-app/README.md +++ b/packages/create-croco-app/README.md @@ -14,12 +14,14 @@ provides generated-app smoke coverage for the supported presets. ## Usage ```bash -pnpm create croco-app my-service -pnpm create croco-app my-service --preset saas-api --package-manager pnpm --json +npx create-croco-app@latest my-saas-api --goal saas-api --scope @myorg --no-install --no-git +cd my-saas-api && pnpm install && pnpm demo:smoke ``` -Generated projects include the package manager command and next-step instructions in -the CLI result. +The `saas-api` goal generates the REST SaaS workspace, provider and tenant manifests, +and the zero-credential `demo:smoke` success path. Generated projects are pnpm +workspaces; `--no-install --no-git` keeps the documented setup deterministic before +the explicit install and smoke commands. ## Verification diff --git a/packages/create-croco-app/src/cli-program.ts b/packages/create-croco-app/src/cli-program.ts new file mode 100644 index 000000000..a10963055 --- /dev/null +++ b/packages/create-croco-app/src/cli-program.ts @@ -0,0 +1,48 @@ +import { Command } from "commander"; +import { getPackageVersion } from "./package-version.js"; +import { formatSaasProviderProfileChoices } from "./saas-provider-profiles.js"; + +export function createCreateCrocoAppProgram(): Command { + return configureCreateCrocoAppProgram(new Command()); +} + +export function configureCreateCrocoAppProgram(program: Command): Command { + return program + .name("create-croco-app") + .description("Create a pnpm-based Croco application") + .version(getPackageVersion()) + .argument("[directory]", "Target directory") + .option( + "--goal ", + "App goal (saas-api|spa-backend-split|worker|internal-tool). Chooses the supported stack and writes croco.app.json", + ) + .option( + "--preset ", + [ + "Project preset (blank|ddd-api|ddd-fullstack|ddd-vike-fullstack|production-app|admin-console|saas|ai-saas).", + "ddd-vike-fullstack is a legacy compatibility name for the meta-vite Worker profile", + ].join(" "), + ) + .option("--scope ", "Package scope (e.g. @myorg)") + .option( + "--saas-profile ", + `Production SaaS provider profile (${formatSaasProviderProfileChoices()})`, + ) + .option( + "--tenant-model ", + "SaaS tenant model (single|org|workspace|shared-schema|rls-backed)", + ) + .option("--api ", "API type (graphql|trpc)") + .option("--api-hosting ", "API hosting (standalone|nextjs)") + .option("--web-apps ", "Comma-separated web app names") + .option("--backend-deploy ", "Backend deploy (docker|lambda)") + .option( + "--frontend-deploy ", + "Frontend deploy (opennext|vercel|docker|cloudflare-meta-vite|vite-spa)", + ) + .option("--db ", "Comma-separated DB types (postgres,mongodb,redis)") + .option("--no-agent-rules", "Skip agent rules") + .option("--no-install", "Skip pnpm dependency installation") + .option("--no-git", "Skip git initialization") + .option("--json", "Print a machine-readable JSON result"); +} diff --git a/packages/create-croco-app/src/cli.ts b/packages/create-croco-app/src/cli.ts index 19f40560f..eee84e273 100644 --- a/packages/create-croco-app/src/cli.ts +++ b/packages/create-croco-app/src/cli.ts @@ -1,5 +1,4 @@ import { intro, outro } from "@clack/prompts"; -import { Command } from "commander"; import { createFailureResult, createSuccessResult, @@ -7,53 +6,13 @@ import { formatHumanSuccess, formatJsonResult, } from "./cli-result.js"; +import { createCreateCrocoAppProgram } from "./cli-program.js"; import { InvalidCliOptionProblem } from "./libs/problems/InvalidCliOptionProblem.js"; -import { getPackageVersion } from "./package-version.js"; -import { formatSaasProviderProfileChoices } from "./saas-provider-profiles.js"; import type { GeneratorOptions } from "./types.js"; -export function createProgram(): Command { - const program = new Command(); - - program - .name("create-croco-app") - .description("Create a pnpm-based Croco application") - .version(getPackageVersion()) - .argument("[directory]", "Target directory") - .option( - "--goal ", - "App goal (saas-api|spa-backend-split|worker|internal-tool). Chooses the supported stack and writes croco.app.json", - ) - .option( - "--preset ", - [ - "Project preset (blank|ddd-api|ddd-fullstack|ddd-vike-fullstack|production-app|admin-console|saas|ai-saas).", - "ddd-vike-fullstack is a legacy compatibility name for the meta-vite Worker profile", - ].join(" "), - ) - .option("--scope ", "Package scope (e.g. @myorg)") - .option( - "--saas-profile ", - `Production SaaS provider profile (${formatSaasProviderProfileChoices()})`, - ) - .option( - "--tenant-model ", - "SaaS tenant model (single|org|workspace|shared-schema|rls-backed)", - ) - .option("--api ", "API type (graphql|trpc)") - .option("--api-hosting ", "API hosting (standalone|nextjs)") - .option("--web-apps ", "Comma-separated web app names") - .option("--backend-deploy ", "Backend deploy (docker|lambda)") - .option( - "--frontend-deploy ", - "Frontend deploy (opennext|vercel|docker|cloudflare-meta-vite|vite-spa)", - ) - .option("--db ", "Comma-separated DB types (postgres,mongodb,redis)") - .option("--no-agent-rules", "Skip agent rules") - .option("--no-install", "Skip pnpm dependency installation") - .option("--no-git", "Skip git initialization") - .option("--json", "Print a machine-readable JSON result") - .action(async (directory: string | undefined, rawOptions: Record) => { +export function createProgram(): ReturnType { + return createCreateCrocoAppProgram().action( + async (directory: string | undefined, rawOptions: Record) => { const outputJson = rawOptions.json === true; try { @@ -105,7 +64,6 @@ export function createProgram(): Command { console.error(outputJson ? formatJsonResult(result) : formatHumanFailure(result)); process.exit(1); } - }); - - return program; + }, + ); } diff --git a/packages/create-croco-app/src/tests/options.spec.ts b/packages/create-croco-app/src/tests/options.spec.ts index 86b7f71ce..56cedc7cc 100644 --- a/packages/create-croco-app/src/tests/options.spec.ts +++ b/packages/create-croco-app/src/tests/options.spec.ts @@ -2,6 +2,7 @@ import { Problem } from "@croco/problems-core"; import { existsSync, rmSync } from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createProgram } from "../cli.js"; +import { createCreateCrocoAppProgram } from "../cli-program.js"; import { InvalidGoalOptionProblem } from "../libs/problems/InvalidGoalOptionProblem.js"; import { normalizeNonInteractiveOptions, @@ -44,6 +45,23 @@ describe("noninteractive CLI option validation", () => { expect(help).not.toContain("--package-manager"); }); + it("parses the canonical documentation command through the action-free CLI contract", () => { + const program = createCreateCrocoAppProgram().exitOverride(); + + program.parse( + ["my-saas-api", "--goal", "saas-api", "--scope", "@myorg", "--no-install", "--no-git"], + { from: "user" }, + ); + + expect(program.processedArgs).toEqual(["my-saas-api"]); + expect(program.opts()).toMatchObject({ + goal: "saas-api", + scope: "@myorg", + install: false, + git: false, + }); + }); + it("marks retained Vike preset naming as meta-vite compatibility", () => { expect(CREATE_CROCO_APP_COMPATIBILITY_CHOICES.presets["ddd-vike-fullstack"]).toEqual({ status: "legacy-compatibility-name", diff --git a/packages/create-croco-app/src/verification.ts b/packages/create-croco-app/src/verification.ts new file mode 100644 index 000000000..73c76bb8e --- /dev/null +++ b/packages/create-croco-app/src/verification.ts @@ -0,0 +1,7 @@ +export { createCreateCrocoAppProgram } from "./cli-program.js"; +export { generate } from "./generator.js"; +export { + isNonInteractiveOptions, + normalizeNonInteractiveOptions, + parseCliOptions, +} from "./options.js"; diff --git a/packages/create-croco-app/tsup.config.ts b/packages/create-croco-app/tsup.config.ts index 300106113..34be0d2bb 100644 --- a/packages/create-croco-app/tsup.config.ts +++ b/packages/create-croco-app/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/verification.ts"], format: ["esm"], target: "node22", clean: true, diff --git a/packages/docs/src/content/docs/en/guides/getting-started.mdx b/packages/docs/src/content/docs/en/guides/getting-started.mdx index 91ea3678e..2ca05e41f 100644 --- a/packages/docs/src/content/docs/en/guides/getting-started.mdx +++ b/packages/docs/src/content/docs/en/guides/getting-started.mdx @@ -1,250 +1,127 @@ --- title: Getting Started -description: Build and deploy a production-ready SaaS backend on AWS Lambda in 5 minutes with Croco. +description: Generate and validate Croco's REST SaaS golden path with one canonical CLI goal. --- import { Aside } from "@astrojs/starlight/components"; ## 1. Introduction -Croco provides a **SaaS backend toolkit** β€” fully typed, Lambda-ready, with auth, metering, and -billing built into the framework. The `ddd-api` preset gives you a basic DDD skeleton to start -from; for a complete SaaS example with auth and metering wired together, skip straight to the Quick -Start Lambda example, or use the SaaS Billing Golden Path for billing, retry, transactions, events, -and Problem recovery. +Croco provides a **SaaS backend toolkit** β€” fully typed, with auth, metering, billing, tenant +isolation, REST contracts, and operational diagnostics built into the framework. The `saas-api` +goal selects the supported generated stack and records it in `croco.app.json`. -This guide takes you from zero to a deployed API: scaffold the project, add an authenticated endpoint, layer on metering, and export as a Lambda handler. +This guide follows one executable loop: generate the REST SaaS workspace, install it, pass its +zero-credential demo smoke, inspect its routes, and extend the same generated controller. ## 2. Quick Start -Create a new project with the CLI. The `ddd-api` preset generates a basic DDD skeleton (Drizzle ORM + env config) β€” a clean foundation to build on: +Create the canonical SaaS REST workspace with the CLI: ```bash -npx create-croco-app@latest my-project --preset ddd-api --scope @myorg --api graphql --backend-deploy lambda --no-install --no-git +npx create-croco-app@latest my-saas-api --goal saas-api --scope @myorg --no-install --no-git ``` -This runs noninteractively with the `ddd-api` preset, `@myorg` package scope, GraphQL API, and Lambda backend. Replace `@myorg` with your package scope. -Generated Croco projects are pnpm workspaces, and the CLI uses pnpm when dependency installation is enabled. +This runs noninteractively with the `saas-api` goal and `@myorg` package scope. It generates a REST +SaaS workspace with `apps/api-server/src/controllers/SaasController.ts`, +`apps/api-server/src/controllers/OperationsController.ts`, provider and tenant manifests, and a +`croco.app.json` goal contract. Replace `@myorg` with your package scope. -Install dependencies and start the dev server: +Install dependencies and run the documented success scenario: ```bash -cd my-project && pnpm install && pnpm dev +cd my-saas-api && pnpm install && pnpm demo:smoke ``` -Your API is now running at `http://localhost:3000/api`. Let's add some SaaS features. +Success means `demo:smoke` completes the generated REST contract checks, in-memory SaaS demo flow, +and operational smoke without external credentials. It does not start a long-running server. -## 3. Your First SaaS Endpoint +## 3. Inspect the Generated REST API -Croco treats auth as a first-class concern. Wrap any controller method with `@UseGuards(AuthGuard)` and authentication is handled before your code runs. +The generated Node REST application registers +`apps/api-server/src/controllers/SaasController.ts` and +`apps/api-server/src/controllers/OperationsController.ts`. `SaasController` exposes the local demo +routes under `/saas`; `OperationsController` exposes health and diagnostics under `/ops`. -First, register an auth provider and guard: +Start that generated application with its demo endpoints enabled: -`TestAuthProvider` is generated inside the quick-start project; this startup wiring is shown in the -context of that generated application. - -```typescript no-check -import { AUTH_PROVIDER_TOKEN, AuthGuard } from "@croco/auth-core"; -import { Container } from "@croco/framework-context"; -import { TestAuthProvider } from "../integrations/TestAuthProvider"; - -Container.set(AUTH_PROVIDER_TOKEN, new TestAuthProvider()); -Container.set(AuthGuard, new AuthGuard()); +```bash +SAAS_DEMO_ENDPOINTS_ENABLED=true pnpm --filter @myorg/api-server dev ``` -Now create a controller with a protected endpoint: - -```typescript typecheck -import { AuthGuard } from "@croco/auth-core"; -import { Controller, Get, UseGuards } from "@croco/protocols-rest"; - -@Controller("/api/users") -class UserController { - private readonly users = { - list: () => [{ id: "user-123", email: "user@example.com" }], - }; +In another terminal, seed the in-memory SaaS runtime and read the deterministic smoke result: - @Get() - @UseGuards(AuthGuard) - list(): { id: string; email: string }[] { - return this.users.list(); - } -} +```bash +curl -X POST http://localhost:3000/saas/demo/seed +curl http://localhost:3000/saas/demo/smoke +curl http://localhost:3000/ops/health ``` -Any request without a valid API key gets a `401 Unauthorized` automatically. No middleware to configure β€” the guard handles it. +The `/saas/demo/*` routes are disabled unless `SAAS_DEMO_ENDPOINTS_ENABLED=true` and remain disabled +in production. The CLI `pnpm demo:smoke` path does not require opening those HTTP routes. -## 4. Add Metering +## 4. Extend the Generated Controller -Track usage for billing with a single decorator. Croco's `@Metered` records every invocation against a named meter: - -The metering adapter below is generated in the quick-start project and bound once at application -startup. +Add REST behavior in `apps/api-server/src/controllers/SaasController.ts`, where the generated app +already keeps route metadata and Problem responses together: ```typescript no-check -import { Meter, Metered, setMeteringService } from "@croco/metering-core"; -import { createMeteringService } from "../integrations/inMemoryMetering"; - -// Wire up the metering infrastructure once, at app startup -setMeteringService(createMeteringService()); - -@Meter({ meterId: "api_user_create" }) -@Controller("/api/users") -class UserController { - @Post() - @UseGuards(AuthGuard) - @Metered({ meterId: "api_user_create" }) - create(@Body() body: CreateUserBody) { - return this.users.create(body); +@Component() +@Controller("/saas") +export class SaasController { + @Post(seedSaasDemoRoute) + @ProblemResponses(...routeProblemResponses(seedSaasDemoRoute)) + async seedDemo() { + await assertDemoEndpointsEnabled(); + const { seedDefaultSaasRuntime } = await import("../saasDemo"); + return seedDefaultSaasRuntime(); } -} -``` - -Now every `POST /api/users` call increments the `api_user_create` meter β€” ready to drive billing, usage limits, or analytics. - -## 5. Deploy to Lambda - -Export your app as a Lambda handler with one line: - -```typescript typecheck -import { Controller, Get } from "@croco/protocols-rest"; -import { createApp } from "@croco/transports-http"; -@Controller("/api/users") -class UserController { - @Get() - list(): { id: string; email: string }[] { - return [{ id: "user-123", email: "user@example.com" }]; + @Get(smokeSaasDemoRoute) + @ProblemResponses(...routeProblemResponses(smokeSaasDemoRoute)) + async smokeDemo() { + await assertDemoEndpointsEnabled(); + const { seedDefaultSaasRuntime } = await import("../saasDemo"); + return seedDefaultSaasRuntime(); } } - -const app = createApp({ - controllers: [UserController], -}); - -export const handler = app.lambdaHandler(); ``` -That's it. `handler` is a standard AWS Lambda entrypoint β€” deploy it with SST, Terraform, CDK, or any Lambda-compatible platform. - -## 6. Complete Example - -The quick-start-lambda example above is the **complete, runnable reference** for everything covered -in this guide. The `saas-billing-golden-path` example is the checked-in reference for the billing -success path: checkout, transient payment retry, transactional event publication, audit projection, -and explicit Problem recovery. - -The repository validates those references with: +After changing route metadata or behavior, re-run the generated contract and success gates: ```bash -pnpm quick-start-lambda:smoke -pnpm saas-billing-golden-path:smoke -pnpm first-success:verify +pnpm contract:check +pnpm demo:smoke ``` -The SaaS smoke command builds the example and its workspace dependencies before it runs the -checked-in golden-path tests. The first-success verifier keeps this guide, the root README, release -spine docs, public scaffold commands, and checked examples pointed at the same commands. +`contract:check` fails on invalid REST schemas or Problem contracts. `demo:smoke` then proves the +same generated workspace still passes profile, architecture, runtime-policy, contract, SaaS flow, +operations, and job checks. -Here's the project structure from `quick-start-lambda`, bringing together auth, metering, protocol metadata, transport wiring, and Lambda export in one runnable reference: +## 5. Separate Checked-in References -``` -my-saas-api/ -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ app/ -β”‚ β”‚ └── bootstrap.ts # DI, integrations, createApp -β”‚ β”œβ”€β”€ domain/ -β”‚ β”‚ └── UserService.ts # App/domain behavior -β”‚ β”œβ”€β”€ integrations/ -β”‚ β”‚ β”œβ”€β”€ TestAuthProvider.ts # Replaceable auth provider -β”‚ β”‚ └── inMemoryMetering.ts # Replaceable metering storage -β”‚ β”œβ”€β”€ protocols/ -β”‚ β”‚ β”œβ”€β”€ HealthController.ts # REST health metadata -β”‚ β”‚ └── UserController.ts # REST user metadata -β”‚ └── index.ts # Lambda export + local dev start -└── package.json -``` - -`src/protocols/UserController.ts` owns protocol metadata and remains transport-neutral: - -```typescript no-check -import { AuthGuard } from "@croco/auth-core"; -import { Container } from "@croco/framework-context"; -import { Meter, Metered } from "@croco/metering-core"; -import { Body, Controller, Get, Post, UseGuards } from "@croco/protocols-rest"; -import { type CreateUserBody, UserService } from "../domain/UserService"; - -@Meter({ meterId: "api_user_create" }) -@Controller("/api/users") -export class UserController { - private readonly users: UserService; - - constructor() { - this.users = Container.get(UserService); - } +The scaffold journey is complete at this point. For deployment-specific or deeper domain examples, +use these separate checked-in references rather than treating them as generated files: - @Get() - @UseGuards(AuthGuard) - list() { - return this.users.list(); - } - - @Post() - @UseGuards(AuthGuard) - @Metered({ meterId: "api_user_create" }) - create(@Body() body: CreateUserBody) { - return this.users.create(body); - } -} -``` +- [`examples/quick-start-lambda`](https://github.com/croco-dev/framework/tree/trunk/examples/quick-start-lambda) + demonstrates Lambda export, auth, metering, and DI wiring. Validate it with + `pnpm quick-start-lambda:smoke`. +- [`examples/saas-billing-golden-path`](https://github.com/croco-dev/framework/tree/trunk/examples/saas-billing-golden-path) + demonstrates checkout, retry, transactional events, audit projection, and explicit Problem + recovery. Validate it with `pnpm saas-billing-golden-path:smoke`. -`src/app/bootstrap.ts` owns runtime composition, so auth and metering adapters can change without changing controller or domain code: - -```typescript no-check -import { AUTH_PROVIDER_TOKEN, AuthGuard } from "@croco/auth-core"; -import { Container } from "@croco/framework-context"; -import { setMeteringService } from "@croco/metering-core"; -import { createApp } from "@croco/transports-http"; -import { TestAuthProvider } from "../integrations/TestAuthProvider"; -import { createMeteringService } from "../integrations/inMemoryMetering"; -import { HealthController } from "../protocols/HealthController"; -import { UserController } from "../protocols/UserController"; - -setMeteringService(createMeteringService()); -Container.set(AUTH_PROVIDER_TOKEN, new TestAuthProvider()); -Container.set(AuthGuard, new AuthGuard()); -Container.set(HealthController, new HealthController()); -Container.set(UserController, new UserController()); - -export const app = createApp({ - controllers: [HealthController, UserController], - securityValidation: "off", -}); -``` - -`src/index.ts` stays thin and marks the Lambda transport boundary: - -```typescript no-check -import "reflect-metadata"; -import { createLambdaExampleApp, startLocalServer } from "./app/bootstrap"; - -const app = createLambdaExampleApp(); -export const handler = app.lambdaHandler(); - -if (process.env.NODE_ENV !== "production") { - startLocalServer(app); -} -``` +The repository-level `pnpm first-success:verify` gate keeps the public scaffold commands and this +generated SaaS route/runtime walkthrough aligned with the actual CLI contract. -## 7. Next Steps +## 6. Next Steps Now that you have a running SaaS API, explore what Croco can do: diff --git a/packages/docs/src/content/docs/en/index.mdx b/packages/docs/src/content/docs/en/index.mdx index e04b015d2..380a4ee66 100644 --- a/packages/docs/src/content/docs/en/index.mdx +++ b/packages/docs/src/content/docs/en/index.mdx @@ -9,7 +9,7 @@ import { Card, CardGrid } from "@astrojs/starlight/components"; CROCO is an opinionated TypeScript framework that combines HTTP routing, DDD events, transactions, and **built-in SaaS primitives** (auth, billing, metering, tenant) into a single unified decorator and type system. -> `npx create-croco-app@latest my-saas-api --preset ddd-api --scope @myorg --api graphql --backend-deploy lambda --no-install --no-git` β†’ 5 minutes to a production-ready SaaS backend. +> `npx create-croco-app@latest my-saas-api --goal saas-api --scope @myorg --no-install --no-git` β†’ a generated REST SaaS workspace with a zero-credential `pnpm demo:smoke` success path. ## Why Croco? diff --git a/scripts/alpha-release-smoke.mts b/scripts/alpha-release-smoke.mts index 8a1195d25..5457e9417 100644 --- a/scripts/alpha-release-smoke.mts +++ b/scripts/alpha-release-smoke.mts @@ -66,7 +66,7 @@ type SmokeReport = { readonly error?: string; readonly generatedAppDirectory?: string; readonly packedPackageCount: number; - readonly smokeCase: typeof alphaReleaseGeneratedAppSmoke; + readonly smokeCases: typeof alphaReleaseGeneratedAppSmokeCases; readonly spineRoots: readonly string[]; readonly status: "PASS" | "FAIL"; readonly validations: readonly string[]; @@ -81,24 +81,46 @@ const skippedPackageJsonDirectories = new Set([".turbo", "coverage", "dist", "no export const alphaReleaseEvidenceReportPath = "ci-reports/release/alpha-release-smoke.md"; +export const alphaReleaseGeneratedAppValidations = [ + "contract:snapshot", + "contract:verify", + "typecheck", + "build", + "test", + "dev:smoke", +] as const; + export const alphaReleaseGeneratedAppSmoke = { args: ["--preset", "production-app", "--scope", "@alpha", "--no-install", "--no-git"], name: "alpha-production-app", preset: "production-app", } as const; -export const alphaReleaseGeneratedAppValidations = [ - "contract:snapshot", - "contract:verify", +export const alphaReleaseCanonicalSaasValidations = [ "typecheck", "build", "test", - "dev:smoke", + "demo:smoke", +] as const; + +export const alphaReleaseGeneratedAppSmokeCases = [ + { + ...alphaReleaseGeneratedAppSmoke, + validations: alphaReleaseGeneratedAppValidations, + }, + { + args: ["--goal", "saas-api", "--scope", "@myorg", "--no-install", "--no-git"], + goal: "saas-api", + name: "my-saas-api", + preset: "saas", + validations: alphaReleaseCanonicalSaasValidations, + }, ] as const; export const alphaReleaseBinarySmokeCommands = [ "pnpm exec create-croco-app --version", "pnpm exec create-croco-app --preset production-app --scope @alpha --no-install --no-git", + "pnpm exec create-croco-app my-saas-api --goal saas-api --scope @myorg --no-install --no-git", "pnpm exec croco --help", ] as const; @@ -131,10 +153,12 @@ function main(): void { cleanInstallImportExclusions: alphaReleaseCleanInstallImportExclusions, cleanInstallImports: alphaReleaseCleanInstallImportPackages, packedPackageCount: 0, - smokeCase: alphaReleaseGeneratedAppSmoke, + smokeCases: alphaReleaseGeneratedAppSmokeCases, spineRoots: alphaReleaseSpineRoots, status: "FAIL", - validations: alphaReleaseGeneratedAppValidations, + validations: [ + ...new Set(alphaReleaseGeneratedAppSmokeCases.flatMap((smokeCase) => smokeCase.validations)), + ], }; try { @@ -158,13 +182,17 @@ function main(): void { spineCoverage.cleanInstallImports, rootDir, ); - const generatedAppDirectory = runPackedCreateCrocoAppSmoke( - smokeRoot, - packageIndex, - spineOverrides, - rootDir, - packedPackages, + const generatedAppDirectories = alphaReleaseGeneratedAppSmokeCases.map((smokeCase) => + runPackedCreateCrocoAppSmoke( + smokeRoot, + packageIndex, + spineOverrides, + rootDir, + packedPackages, + smokeCase, + ), ); + const generatedAppDirectory = generatedAppDirectories.at(-1); report = { cleanInstallImportExclusions: spineCoverage.cleanInstallImportExclusions, @@ -172,10 +200,14 @@ function main(): void { cleanInstallImports: spineCoverage.cleanInstallImports, generatedAppDirectory, packedPackageCount: packedPackages.size, - smokeCase: alphaReleaseGeneratedAppSmoke, + smokeCases: alphaReleaseGeneratedAppSmokeCases, spineRoots: spineCoverage.spineRoots, status: "PASS", - validations: alphaReleaseGeneratedAppValidations, + validations: [ + ...new Set( + alphaReleaseGeneratedAppSmokeCases.flatMap((smokeCase) => smokeCase.validations), + ), + ], }; console.log("alpha-release-smoke: clean install and packed generated app smoke passed"); @@ -591,9 +623,10 @@ function runPackedCreateCrocoAppSmoke( spineOverrides: Record, rootDir: string, packedPackages: Map, + smokeCase: (typeof alphaReleaseGeneratedAppSmokeCases)[number], ): string { const cliConsumerDir = join(smokeRoot, "create-croco-app-consumer"); - const projectDir = join(smokeRoot, alphaReleaseGeneratedAppSmoke.name); + const projectDir = join(smokeRoot, smokeCase.name); mkdirSync(cliConsumerDir, { recursive: true }); writePackageJson(cliConsumerDir, { name: "croco-alpha-create-app-consumer", @@ -607,11 +640,7 @@ function runPackedCreateCrocoAppSmoke( ["add", "--prod", "--ignore-scripts", rangeFor(spineOverrides, "create-croco-app")], cliConsumerDir, ); - run( - "pnpm", - ["exec", "create-croco-app", projectDir, ...alphaReleaseGeneratedAppSmoke.args], - cliConsumerDir, - ); + run("pnpm", ["exec", "create-croco-app", projectDir, ...smokeCase.args], cliConsumerDir); const generatedPackageIndex = createWorkspacePackageIndex(rootDir); const generatedPackages = resolveLocalCrocoPackagesForGeneratedProject( @@ -640,9 +669,9 @@ function runPackedCreateCrocoAppSmoke( "packed create-croco-app generated app", ); - for (const validation of alphaReleaseGeneratedAppValidations) { + for (const validation of smokeCase.validations) { run("pnpm", [validation], projectDir); - console.log(`alpha-release-smoke: generated app ${validation} passed`); + console.log(`alpha-release-smoke: ${smokeCase.name} ${validation} passed`); } return projectDir; @@ -855,7 +884,13 @@ export function formatAlphaReleaseSmokeReport(report: SmokeReport): string { `- Clean install imports: ${report.cleanInstallImports.map((packageName) => `\`${packageName}\``).join(", ")}`, `- Clean install import exclusions: ${cleanInstallImportExclusions}`, `- Packed package tarballs: ${report.packedPackageCount}`, - `- Generated app preset: \`${report.smokeCase.preset}\``, + `- Generated app cases: ${report.smokeCases + .map((smokeCase) => + "goal" in smokeCase + ? `\`${smokeCase.goal}\` goal (\`${smokeCase.name}\`)` + : `\`${smokeCase.preset}\` preset (\`${smokeCase.name}\`)`, + ) + .join(", ")}`, `- Generated app validations: ${report.validations.map((validation) => `\`pnpm ${validation}\``).join(", ")}`, ]; @@ -877,9 +912,9 @@ export function formatAlphaReleaseSmokeReport(report: SmokeReport): string { "", "- The alpha spine package set installs into a clean project from packed artifacts.", "- Config-free alpha spine entrypoints import successfully from the clean install.", - "- The packed create-croco-app artifact generates the production-app preset outside the repository checkout.", + "- The packed create-croco-app artifact preserves the production-app smoke and generates the canonical saas-api goal outside the repository checkout.", "- Generated app install uses packed Croco artifacts with no `@croco/*` workspace ranges.", - "- Contract verification, typecheck, build, test, and zero-credential smoke run against the generated project.", + "- The canonical SaaS project passes typecheck, build, test, and the documented zero-credential `demo:smoke` scenario.", ); return `${lines.join("\n")}\n`; diff --git a/scripts/create-croco-app-generated-smoke.mts b/scripts/create-croco-app-generated-smoke.mts index a67064313..625ebec42 100644 --- a/scripts/create-croco-app-generated-smoke.mts +++ b/scripts/create-croco-app-generated-smoke.mts @@ -516,6 +516,7 @@ const smokeCaseDefinitions: readonly Omit[] = [ { label: "typecheck", args: ["typecheck"] }, { label: "build", args: ["build"] }, { label: "test", args: ["test"] }, + { label: "demo flow", args: ["demo:smoke"] }, { label: "failure drill smoke", args: ["failure-drill:smoke"], diff --git a/scripts/first-success-generated-contract.mts b/scripts/first-success-generated-contract.mts new file mode 100644 index 000000000..25b2658a0 --- /dev/null +++ b/scripts/first-success-generated-contract.mts @@ -0,0 +1,117 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +type PackageJson = { + readonly name?: unknown; + readonly scripts?: Record; +}; + +const generatedControllerPaths = [ + "apps/api-server/src/controllers/SaasController.ts", + "apps/api-server/src/controllers/OperationsController.ts", +] as const; + +export function validateGeneratedSaasDocsContract( + targetDir: string, + docsContent: string, +): string[] { + const failures: string[] = []; + + for (const controllerPath of generatedControllerPaths) { + if (!existsSync(join(targetDir, controllerPath))) { + failures.push(`generated REST controller is missing: ${controllerPath}`); + } + if (!docsContent.includes(controllerPath)) { + failures.push(`docs missing generated SaaS controller path \`${controllerPath}\``); + } + } + + const rootPackageJson = readPackageJson(join(targetDir, "package.json")); + const apiPackageJson = readPackageJson(join(targetDir, "apps/api-server/package.json")); + const apiPackageName = typeof apiPackageJson.name === "string" ? apiPackageJson.name : undefined; + const providerProfiles = readFileSync( + join(targetDir, "apps/api-server/src/providerProfiles.ts"), + "utf-8", + ); + const serverIndex = readFileSync(join(targetDir, "apps/api-server/src/index.ts"), "utf-8"); + const routeSchemas = readFileSync( + join(targetDir, "apps/api-server/src/controllers/schemas.ts"), + "utf-8", + ); + + const demoEndpointsEnv = extractRequiredMatch( + providerProfiles, + /SAAS_DEMO_ENDPOINTS_ENABLED_ENV\s*=\s*['"]([^'"]+)['"]/, + "generated demo endpoint environment variable", + failures, + ); + const port = extractRequiredMatch( + serverIndex, + /Number\(value\s*\?\?\s*(\d+)\)/, + "generated default HTTP port", + failures, + ); + const seedPath = extractRoutePath(routeSchemas, "seedSaasDemoRoute", failures); + const smokePath = extractRoutePath(routeSchemas, "smokeSaasDemoRoute", failures); + const healthPath = extractRoutePath(routeSchemas, "healthRoute", failures); + + if (!apiPackageName) { + failures.push("generated apps/api-server/package.json is missing package name"); + } + if (!rootPackageJson.scripts?.["contract:check"]) { + failures.push("generated package.json is missing scripts.contract:check"); + } + + const derivedDocsContracts = [ + apiPackageName && demoEndpointsEnv + ? `${demoEndpointsEnv}=true pnpm --filter ${apiPackageName} dev` + : undefined, + port && seedPath ? `http://localhost:${port}${seedPath}` : undefined, + port && smokePath ? `http://localhost:${port}${smokePath}` : undefined, + port && healthPath ? `http://localhost:${port}${healthPath}` : undefined, + rootPackageJson.scripts?.["contract:check"] ? "pnpm contract:check" : undefined, + ].filter((contract): contract is string => contract !== undefined); + + for (const contract of derivedDocsContracts) { + if (!docsContent.includes(contract)) { + failures.push(`docs missing generated SaaS runtime contract \`${contract}\``); + } + } + + return failures; +} + +function extractRoutePath( + content: string, + routeName: string, + failures: string[], +): string | undefined { + const match = new RegExp( + `export const ${routeName} = defineRouteContract\\(\\{[\\s\\S]*?\\bpath:\\s*['"]([^'"]+)['"]`, + ).exec(content); + + if (!match?.[1]) { + failures.push(`generated route contract is missing a path: ${routeName}`); + return undefined; + } + + return match[1]; +} + +function extractRequiredMatch( + content: string, + pattern: RegExp, + label: string, + failures: string[], +): string | undefined { + const value = pattern.exec(content)?.[1]; + if (!value) { + failures.push(`${label} is not inspectable`); + } + return value; +} + +function readPackageJson(path: string): PackageJson { + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + return typeof parsed === "object" && parsed !== null ? (parsed as PackageJson) : {}; +} diff --git a/scripts/first-success-verify.mts b/scripts/first-success-verify.mts index 034bbb539..d55392b0a 100644 --- a/scripts/first-success-verify.mts +++ b/scripts/first-success-verify.mts @@ -4,12 +4,39 @@ * Verifies the first-success journey contract by parsing source files. * Ensures README documentation, source code, docs, and scaffold stay in sync. * - * Usage: node --experimental-strip-types scripts/first-success-verify.mts + * Usage: pnpm first-success:verify * Exit: 0 = all contracts pass, 1 = any contract fails */ -import { readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type * as CreateCrocoAppVerification from "../packages/create-croco-app/src/verification.ts"; +import { validateGeneratedSaasDocsContract } from "./first-success-generated-contract.mts"; + +const scriptRepoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cliContractBundle = join( + scriptRepoRoot, + "packages", + "create-croco-app", + "dist", + "verification.js", +); + +if (!existsSync(cliContractBundle)) { + throw new Error( + `Missing create-croco-app verification contract: ${cliContractBundle}. Run pnpm first-success:verify so the CLI contract is built first.`, + ); +} + +const { + createCreateCrocoAppProgram, + generate, + isNonInteractiveOptions, + normalizeNonInteractiveOptions, + parseCliOptions, +} = (await import(pathToFileURL(cliContractBundle).href)) as typeof CreateCrocoAppVerification; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -32,11 +59,6 @@ type RootReadmeToolingCommand = { readonly scriptName: string; }; -type ParsedCreateCrocoAppCommand = { - readonly projectName?: string; - readonly flags: Map; -}; - type PackageJsonWithScripts = { readonly scripts?: Record; }; @@ -87,14 +109,6 @@ const firstSuccessCommandContracts = [ }, ] as const; -const CREATE_CROCO_APP_CHOICES = new Map([ - ["--preset", ["blank", "ddd-api", "ddd-fullstack", "ddd-vike-fullstack", "production-app"]], - ["--api", ["graphql", "trpc"]], - ["--api-hosting", ["standalone", "nextjs"]], - ["--backend-deploy", ["docker", "lambda"]], - ["--frontend-deploy", ["opennext", "vercel", "docker", "cloudflare-meta-vite", "vite-spa"]], -]); - function pass(label: string, detail?: string): void { console.log(` βœ… ${label}${detail ? ` β€” ${detail}` : ""}`); } @@ -280,9 +294,9 @@ function extractCreateCrocoAppCommands(content: string): ExtractedCommand[] { } function isCreateCrocoAppCommandSnippet(snippet: string): boolean { - const args = splitShellWords(snippet); + const cliArgs = extractCreateCrocoAppArgs(splitShellWords(snippet)); - return args.length > 1 && args.some(isCreateCrocoAppExecutable); + return cliArgs !== undefined && cliArgs.length > 0; } function splitShellWords(command: string): string[] { @@ -335,183 +349,147 @@ function splitShellWords(command: string): string[] { return words; } -function parseCreateCrocoAppCommand(command: string): ParsedCreateCrocoAppCommand | undefined { - const args = splitShellWords(command); - const executableIndex = args.findIndex(isCreateCrocoAppExecutable); +function extractCreateCrocoAppArgs(args: readonly string[]): string[] | undefined { + const commandBoundary = args.findIndex((arg) => arg === "&&" || arg === ";"); + const commandArgs = commandBoundary === -1 ? [...args] : args.slice(0, commandBoundary); - if (executableIndex === -1) { - return undefined; + if (commandArgs[0] === "npx" && isCreateCrocoAppExecutable(commandArgs[1])) { + return commandArgs.slice(2); } - let projectName: string | undefined; - const flags = new Map(); - - for (let index = executableIndex + 1; index < args.length; index += 1) { - const arg = args[index]; - - if (arg === "&&" || arg === ";") { - break; - } - - if (!arg.startsWith("--")) { - projectName ??= arg; - continue; - } - - const [flag, inlineValue] = arg.split("=", 2); - - if (flag === "--no-install" || flag === "--no-git" || flag === "--no-agent-rules") { - flags.set(flag, false); - continue; - } - - if (inlineValue !== undefined) { - flags.set(flag, inlineValue); - continue; - } - - const value = args[index + 1]; - if (!value || value.startsWith("--") || value === "&&" || value === ";") { - flags.set(flag, ""); - continue; - } + if (commandArgs[0] === "pnpm" && commandArgs[1] === "create" && commandArgs[2] === "croco-app") { + return commandArgs.slice(3); + } - flags.set(flag, value); - index += 1; + if ( + commandArgs[0] === "pnpm" && + commandArgs[1] === "dlx" && + isCreateCrocoAppExecutable(commandArgs[2]) + ) { + return commandArgs.slice(3); } - return { projectName, flags }; -} + if (isCreateCrocoAppExecutable(commandArgs[0])) { + return commandArgs.slice(1); + } -function isCreateCrocoAppExecutable(arg: string): boolean { - return arg === "create-croco-app" || arg.startsWith("create-croco-app@"); + return undefined; } -function readStringFlag(flags: Map, flag: string): string | undefined { - const value = flags.get(flag); - - return typeof value === "string" ? value : undefined; +function isCreateCrocoAppExecutable(arg: string | undefined): boolean { + return arg === "create-croco-app" || arg?.startsWith("create-croco-app@") === true; } -function validatePublicCreateCommand( +async function validatePublicCreateCommand( extracted: ExtractedCommand, source: PublicDocsSource, -): string[] { - const parsed = parseCreateCrocoAppCommand(extracted.command); +): Promise { + const cliArgs = extractCreateCrocoAppArgs(splitShellWords(extracted.command)); - if (!parsed) { + if (!cliArgs) { return [`${source.label}:${extracted.line} could not parse create-croco-app command`]; } const failures: string[] = []; - const preset = readStringFlag(parsed.flags, "--preset"); - const scope = readStringFlag(parsed.flags, "--scope"); - const api = readStringFlag(parsed.flags, "--api"); - const missingRequiredFlags = [ - parsed.projectName ? undefined : "project=", - preset ? undefined : "--preset=", - scope ? undefined : "--scope=", - preset === "ddd-api" || preset === "ddd-fullstack" - ? api - ? undefined - : "--api=" - : undefined, - preset === "ddd-vike-fullstack" && !readStringFlag(parsed.flags, "--frontend-deploy") - ? "--frontend-deploy=" - : undefined, - ].filter((value): value is string => !!value); - - if (missingRequiredFlags.length > 0) { - failures.push( - `${source.label}:${extracted.line} create-croco-app command is missing required noninteractive values: ${missingRequiredFlags.join(", ")}`, - ); - } - - if (scope && !scope.startsWith("@")) { - failures.push(`${source.label}:${extracted.line} create-croco-app --scope must start with @`); - } - for (const [flag, choices] of CREATE_CROCO_APP_CHOICES) { - const value = readStringFlag(parsed.flags, flag); - if (value && !choices.includes(value)) { + if (source.requireSkipFlags) { + const missingSkipFlags = ["--no-install", "--no-git"].filter((flag) => !cliArgs.includes(flag)); + if (missingSkipFlags.length > 0) { failures.push( - `${source.label}:${extracted.line} create-croco-app ${flag} value "${value}" is not one of ${choices.join(", ")}`, + `${source.label}:${extracted.line} create-croco-app command must include ${missingSkipFlags.join(" and ")} before manual install steps`, ); } } - if (preset === "blank") { - for (const unsupported of [ - "--api", - "--api-hosting", - "--backend-deploy", - "--frontend-deploy", - "--web-apps", - "--db", - ]) { - if (parsed.flags.has(unsupported)) { - failures.push( - `${source.label}:${extracted.line} ${unsupported} is not supported with the blank preset`, - ); - } - } - } + const tempRoot = mkdtempSync(join(tmpdir(), "croco-first-success-command-")); + try { + const program = createCreateCrocoAppProgram() + .exitOverride() + .configureOutput({ + writeErr: () => undefined, + writeOut: () => undefined, + }); + program.parse(cliArgs, { from: "user" }); + + const directory = program.processedArgs[0]; + const rawOptions = program.opts>(); + const cliOptions = parseCliOptions( + typeof directory === "string" ? directory : undefined, + rawOptions, + ); - if (preset === "ddd-api") { - if (parsed.flags.has("--web-apps")) { - failures.push( - `${source.label}:${extracted.line} --web-apps is only supported with the ddd-fullstack preset`, - ); - } - if (readStringFlag(parsed.flags, "--api-hosting") === "nextjs") { - failures.push( - `${source.label}:${extracted.line} --api-hosting nextjs is only supported with ddd-fullstack`, - ); + if (!isNonInteractiveOptions(cliOptions)) { + throw new Error("command does not provide directory, --scope, and --goal or --preset"); } - if (parsed.flags.has("--frontend-deploy")) { - failures.push( - `${source.label}:${extracted.line} --frontend-deploy is only supported with fullstack presets`, - ); - } - } - if (preset === "ddd-fullstack" && readStringFlag(parsed.flags, "--api-hosting") === "nextjs") { - const webApps = readStringFlag(parsed.flags, "--web-apps")?.split(",").filter(Boolean) ?? [ - "web", - ]; - if (webApps.length !== 1) { - failures.push( - `${source.label}:${extracted.line} --api-hosting nextjs requires exactly one web app`, - ); - } - if (parsed.flags.has("--backend-deploy")) { + const options = normalizeNonInteractiveOptions(cliOptions); + if (options.goal !== "saas-api" || options.preset !== "saas") { failures.push( - `${source.label}:${extracted.line} --backend-deploy is only supported with standalone API hosting`, + `${source.label}:${extracted.line} create-croco-app command resolves to ${options.goal ? `goal ${options.goal}` : `preset ${options.preset}`}, not the canonical goal saas-api journey`, ); } - } - if ( - preset === "ddd-vike-fullstack" && - readStringFlag(parsed.flags, "--frontend-deploy") !== "cloudflare-meta-vite" - ) { + const targetDir = join(tempRoot, options.projectName); + await generate(targetDir, { + ...options, + installDeps: false, + initGit: false, + }); + failures.push(...validateGeneratedSaasJourney(targetDir, source, extracted)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); failures.push( - `${source.label}:${extracted.line} ddd-vike-fullstack only supports --frontend-deploy cloudflare-meta-vite`, + `${source.label}:${extracted.line} create-croco-app command failed the real CLI contract: ${message}`, ); + } finally { + rmSync(tempRoot, { force: true, recursive: true }); } - if (source.requireSkipFlags) { - const missingSkipFlags = ["--no-install", "--no-git"].filter((flag) => !parsed.flags.has(flag)); - if (missingSkipFlags.length > 0) { - failures.push( - `${source.label}:${extracted.line} create-croco-app command must include ${missingSkipFlags.join(" and ")} before manual install steps`, - ); + return failures; +} + +function validateGeneratedSaasJourney( + targetDir: string, + source: PublicDocsSource, + extracted: ExtractedCommand, +): string[] { + const failures: string[] = []; + const prefix = `${source.label}:${extracted.line}`; + const manifest = parseJsonRecord(readFileSync(join(targetDir, "croco.app.json"), "utf-8")); + const packageJson = parsePackageJson(readFileSync(join(targetDir, "package.json"), "utf-8")); + const generatedReadme = readFileSync(join(targetDir, "README.md"), "utf-8"); + + if (manifest.goal !== "saas-api" || manifest.preset !== "saas" || manifest.protocol !== "rest") { + failures.push(`${prefix} generated manifest does not describe the REST saas-api journey`); + } + if (!packageJson.scripts?.["demo:smoke"]) { + failures.push(`${prefix} generated package.json is missing scripts.demo:smoke`); + } + if (!generatedReadme.includes("pnpm demo:smoke")) { + failures.push(`${prefix} generated README does not document pnpm demo:smoke`); + } + for (const controller of ["OperationsController.ts", "SaasController.ts"]) { + if (!existsSync(join(targetDir, "apps", "api-server", "src", "controllers", controller))) { + failures.push(`${prefix} generated REST controller is missing: ${controller}`); } } + if (source.label === "getting-started guide") { + failures.push( + ...validateGeneratedSaasDocsContract(targetDir, source.content).map( + (failure) => `${prefix} ${failure}`, + ), + ); + } return failures; } +function parseJsonRecord(content: string): Record { + const parsed: unknown = JSON.parse(content); + + return isRecord(parsed) ? parsed : {}; +} + function extractPublicPackageCount(report: string): number | undefined { const match = report.match(/^\|\s*Public packages\s*\|\s*(\d+)\s*\|/m); @@ -645,6 +623,7 @@ const paths = { "getting-started.mdx", ), docsIndex: join(ROOT, "packages", "docs", "src", "content", "docs", "en", "index.mdx"), + createCrocoAppReadme: join(ROOT, "packages", "create-croco-app", "README.md"), packageCatalog: join(ROOT, "docs", "package-catalog.json"), packageDocsReport: join(ROOT, "docs", "package-docs-report.md"), prompts: join(ROOT, "packages", "create-croco-app", "src", "prompts.ts"), @@ -992,6 +971,7 @@ console.log("\nπŸ“‹ E. Docs contract\n"); { const rootReadme = read(paths.rootReadme); const docsIndex = read(paths.docsIndex); + const createCrocoAppReadme = read(paths.createCrocoAppReadme); const gettingStarted = read(paths.gettingStarted); const packageCatalog = read(paths.packageCatalog); const packageDocsReport = read(paths.packageDocsReport); @@ -1012,6 +992,11 @@ console.log("\nπŸ“‹ E. Docs contract\n"); content: gettingStarted, requireSkipFlags: true, }, + { + label: "create-croco-app package README", + content: createCrocoAppReadme, + requireSkipFlags: true, + }, ]; const firstSuccessDocsSources: PublicDocsSource[] = [ { @@ -1048,22 +1033,31 @@ console.log("\nπŸ“‹ E. Docs contract\n"); pass("D2", "Getting started docs document create-croco-app command"); } - const commandFailures = publicDocsSources.flatMap((source) => { - const commands = extractCreateCrocoAppCommands(source.content); + const commandFailures = ( + await Promise.all( + publicDocsSources.map(async (source) => { + const commands = extractCreateCrocoAppCommands(source.content); - if (commands.length === 0) { - return [`${source.label} missing a public create-croco-app command`]; - } + if (commands.length === 0) { + return [`${source.label} missing a public create-croco-app command`]; + } - return commands.flatMap((command) => validatePublicCreateCommand(command, source)); - }); + return ( + await Promise.all(commands.map((command) => validatePublicCreateCommand(command, source))) + ).flat(); + }), + ) + ).flat(); if (commandFailures.length > 0) { for (const commandFailure of commandFailures) { fail("D3", commandFailure); } } else { - pass("D3", "Public create-croco-app commands satisfy the noninteractive contract"); + pass( + "D3", + "Public create-croco-app commands normalize and generate the canonical saas-api journey", + ); } if (gettingStarted.includes("When prompted")) { diff --git a/scripts/tests/alpha-release-smoke.spec.ts b/scripts/tests/alpha-release-smoke.spec.ts index ad4df27a4..74f663b82 100644 --- a/scripts/tests/alpha-release-smoke.spec.ts +++ b/scripts/tests/alpha-release-smoke.spec.ts @@ -7,7 +7,9 @@ import { alphaReleaseCleanInstallImportExclusions, alphaReleaseEvidenceReportPath, alphaReleaseCleanInstallImportPackages, + alphaReleaseCanonicalSaasValidations, alphaReleaseGeneratedAppSmoke, + alphaReleaseGeneratedAppSmokeCases, alphaReleaseGeneratedAppValidations, alphaReleaseSpineRoots, deriveAlphaReleaseCleanInstallImportPackages, @@ -67,6 +69,14 @@ describe("alpha-release-smoke.mts", () => { "test", "dev:smoke", ]); + expect(alphaReleaseGeneratedAppSmokeCases).toContainEqual({ + args: ["--goal", "saas-api", "--scope", "@myorg", "--no-install", "--no-git"], + goal: "saas-api", + name: "my-saas-api", + preset: "saas", + validations: alphaReleaseCanonicalSaasValidations, + }); + expect(alphaReleaseCanonicalSaasValidations).toContain("demo:smoke"); expect(alphaReleaseEvidenceReportPath).toBe("ci-reports/release/alpha-release-smoke.md"); }); @@ -195,7 +205,7 @@ describe("alpha-release-smoke.mts", () => { cleanInstallImports: alphaReleaseCleanInstallImportPackages, generatedAppDirectory: "/tmp/app", packedPackageCount: 12, - smokeCase: alphaReleaseGeneratedAppSmoke, + smokeCases: alphaReleaseGeneratedAppSmokeCases, spineRoots: alphaReleaseSpineRoots, status: "PASS", validations: alphaReleaseGeneratedAppValidations, diff --git a/scripts/tests/first-success-verify.spec.ts b/scripts/tests/first-success-verify.spec.ts index b489703e6..21423cab7 100644 --- a/scripts/tests/first-success-verify.spec.ts +++ b/scripts/tests/first-success-verify.spec.ts @@ -2,12 +2,13 @@ import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { validateGeneratedSaasDocsContract } from "../first-success-generated-contract.mts"; const scriptPath = resolve(__dirname, "../first-success-verify.mts"); const tempRoots: string[] = []; const validCreateCommand = - "npx create-croco-app@latest my-project --preset ddd-api --scope @myorg --api graphql --backend-deploy lambda --no-install --no-git"; + "npx create-croco-app@latest my-saas-api --goal saas-api --scope @myorg --no-install --no-git"; const saasPackageName = "@croco-example/saas-billing-golden-path"; const saasSmokeScript = `pnpm --filter ${saasPackageName}... build && pnpm --filter ${saasPackageName} test`; const fixtureSpineStatusSummary = @@ -47,10 +48,12 @@ type FixtureOptions = { readonly rootReadmeCommand?: string; readonly docsIndexPackageCount?: number; readonly extraReadmeToolingCommand?: string; + readonly gettingStartedDevCommand?: string; readonly gettingStartedPackageCount?: number; readonly includeSaasGettingStartedReference?: boolean; readonly omitReleaseFirstSuccessCommand?: string; readonly omittedReadmeToolingCommand?: string; + readonly packageReadmeCommand?: string | null; readonly rootSaasSmokeScript?: string | null; readonly saasReadmeCommands?: readonly string[]; readonly staleReadmeRoadmapStatus?: boolean; @@ -58,6 +61,15 @@ type FixtureOptions = { }; describe("first-success-verify.mts", () => { + beforeAll(() => { + const result = spawnSync("pnpm", ["build", "--filter=create-croco-app"], { + cwd: resolve(__dirname, "../.."), + encoding: "utf-8", + }); + + expect(result.status, result.stderr || result.stdout).toBe(0); + }); + afterEach(() => { for (const root of tempRoots.splice(0)) { rmSync(root, { force: true, recursive: true }); @@ -73,18 +85,55 @@ describe("first-success-verify.mts", () => { expect(result.stdout).toContain("first-success contract verification PASSED"); }); - it("fails when a public scaffold command omits ddd-api noninteractive values", () => { + it("fails through the real CLI contract when a public scaffold command omits scope", () => { const root = createFixture({ rootReadmeCommand: - "npx create-croco-app@latest my-project --preset ddd-api --backend-deploy lambda --no-install --no-git", + "npx create-croco-app@latest my-saas-api --goal saas-api --no-install --no-git", }); const result = runScript(root); expect(result.status).toBe(1); expect(result.stdout).toContain("README.md"); - expect(result.stdout).toContain("--scope="); - expect(result.stdout).toContain("--api="); + expect(result.stdout).toContain("failed the real CLI contract"); + expect(result.stdout).toContain("directory, --scope, and --goal or --preset"); + }); + + it("fails when a public command uses an option absent from the real Commander surface", () => { + const root = createFixture({ + rootReadmeCommand: + "npx create-croco-app@latest my-saas-api --goal saas-api --scope @myorg --package-manager pnpm --no-install --no-git", + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("unknown option '--package-manager'"); + }); + + it("fails when a valid generated command resolves to a different journey", () => { + const root = createFixture({ + rootReadmeCommand: + "npx create-croco-app@latest my-api --preset ddd-api --scope @myorg --api graphql --no-install --no-git", + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "resolves to preset ddd-api, not the canonical goal saas-api journey", + ); + }); + + it("fails when the create-croco-app package README omits the public command", () => { + const root = createFixture({ packageReadmeCommand: null }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "create-croco-app package README missing a public create-croco-app command", + ); }); it("fails when public package-count claims drift from the generated report", () => { @@ -151,8 +200,48 @@ describe("first-success-verify.mts", () => { ); }); + it("fails when the generated SaaS runtime walkthrough drifts from the scaffold contract", () => { + const root = createFixture({ + gettingStartedDevCommand: + "SAAS_DEMO_ENDPOINTS_ENABLED=true pnpm --filter @wrong/api-server dev", + }); + + const result = runScript(root); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("D3"); + expect(result.stdout).toContain( + "docs missing generated SaaS runtime contract `SAAS_DEMO_ENDPOINTS_ENABLED=true pnpm --filter @myorg/api-server dev`", + ); + }); + + it("fails when the generated runtime port drifts from the documented URLs", () => { + const targetDir = createGeneratedRuntimeFixture(); + const docsContent = [ + "apps/api-server/src/controllers/SaasController.ts", + "apps/api-server/src/controllers/OperationsController.ts", + "SAAS_DEMO_ENDPOINTS_ENABLED=true pnpm --filter @myorg/api-server dev", + "http://localhost:3000/saas/demo/seed", + "http://localhost:3000/saas/demo/smoke", + "http://localhost:3000/ops/health", + "pnpm contract:check", + ].join("\n"); + writeFile(targetDir, "apps/api-server/src/index.ts", "const port = Number(value ?? 4000);\n"); + + const failures = validateGeneratedSaasDocsContract(targetDir, docsContent); + + expect(failures).toContain( + "docs missing generated SaaS runtime contract `http://localhost:4000/saas/demo/seed`", + ); + expect(failures).toContain( + "docs missing generated SaaS runtime contract `http://localhost:4000/ops/health`", + ); + }); + it("fails when the root README drops a checked tooling command", () => { - const root = createFixture({ omittedReadmeToolingCommand: "pnpm docs:catalog:check" }); + const root = createFixture({ + omittedReadmeToolingCommand: "pnpm docs:catalog:check", + }); const result = runScript(root); @@ -164,7 +253,9 @@ describe("first-success-verify.mts", () => { }); it("fails when the root README documents an unknown tooling command", () => { - const root = createFixture({ extraReadmeToolingCommand: "pnpm made-up-tooling-command" }); + const root = createFixture({ + extraReadmeToolingCommand: "pnpm made-up-tooling-command", + }); const result = runScript(root); @@ -176,7 +267,9 @@ describe("first-success-verify.mts", () => { }); it("fails when release spine docs drop a first-success command", () => { - const root = createFixture({ omitReleaseFirstSuccessCommand: "pnpm first-success:verify" }); + const root = createFixture({ + omitReleaseFirstSuccessCommand: "pnpm first-success:verify", + }); const result = runScript(root); @@ -212,13 +305,52 @@ describe("first-success-verify.mts", () => { }); }); +function createGeneratedRuntimeFixture(): string { + const root = mkdtempSync(join(tmpdir(), "croco-generated-runtime-")); + tempRoots.push(root); + writeFile(root, "package.json", JSON.stringify({ scripts: { "contract:check": "croco" } })); + writeFile(root, "apps/api-server/package.json", JSON.stringify({ name: "@myorg/api-server" })); + writeFile( + root, + "apps/api-server/src/controllers/SaasController.ts", + "export class SaasController {}\n", + ); + writeFile( + root, + "apps/api-server/src/controllers/OperationsController.ts", + "export class OperationsController {}\n", + ); + writeFile( + root, + "apps/api-server/src/providerProfiles.ts", + 'export const SAAS_DEMO_ENDPOINTS_ENABLED_ENV = "SAAS_DEMO_ENDPOINTS_ENABLED";\n', + ); + writeFile(root, "apps/api-server/src/index.ts", "const port = Number(value ?? 3000);\n"); + writeFile( + root, + "apps/api-server/src/controllers/schemas.ts", + [ + 'export const healthRoute = defineRouteContract({ path: "/ops/health" });', + 'export const seedSaasDemoRoute = defineRouteContract({ path: "/saas/demo/seed" });', + 'export const smokeSaasDemoRoute = defineRouteContract({ path: "/saas/demo/smoke" });', + "", + ].join("\n"), + ); + return root; +} + function createFixture(options: FixtureOptions = {}): string { const root = mkdtempSync(join(tmpdir(), "croco-first-success-")); tempRoots.push(root); const rootReadmeCommand = options.rootReadmeCommand ?? validCreateCommand; + const packageReadmeCommand = + options.packageReadmeCommand === undefined ? validCreateCommand : options.packageReadmeCommand; const docsIndexPackageCount = options.docsIndexPackageCount ?? 97; const gettingStartedPackageCount = options.gettingStartedPackageCount ?? 97; + const gettingStartedDevCommand = + options.gettingStartedDevCommand ?? + "SAAS_DEMO_ENDPOINTS_ENABLED=true pnpm --filter @myorg/api-server dev"; const rootSaasSmokeScript = options.rootSaasSmokeScript === undefined ? saasSmokeScript : options.rootSaasSmokeScript; const saasReadmeCommands = options.saasReadmeCommands ?? defaultSaasReadmeCommands; @@ -436,6 +568,13 @@ function createFixture(options: FixtureOptions = {}): string { "packages/create-croco-app/src/prompts.ts", ['"ddd-api"', "Basic DDD skeleton (Drizzle ORM + env utils)", ""].join("\n"), ); + writeFile( + root, + "packages/create-croco-app/README.md", + packageReadmeCommand + ? ["# create-croco-app", "", "```bash", packageReadmeCommand, "```", ""].join("\n") + : "# create-croco-app\n", + ); writeFile( root, "packages/docs/src/content/docs/en/index.mdx", @@ -457,6 +596,13 @@ function createFixture(options: FixtureOptions = {}): string { "See examples/quick-start-lambda for a working example.", "pnpm quick-start-lambda:smoke", "pnpm first-success:verify", + "apps/api-server/src/controllers/SaasController.ts", + "apps/api-server/src/controllers/OperationsController.ts", + gettingStartedDevCommand, + "http://localhost:3000/saas/demo/seed", + "http://localhost:3000/saas/demo/smoke", + "http://localhost:3000/ops/health", + "pnpm contract:check", ...(includeSaasGettingStartedReference ? [ "See examples/saas-billing-golden-path for billing, retry, transactions, events, and Problems.", diff --git a/scripts/tests/release-workflow.spec.ts b/scripts/tests/release-workflow.spec.ts index 5b2c8c9d2..d17e45280 100644 --- a/scripts/tests/release-workflow.spec.ts +++ b/scripts/tests/release-workflow.spec.ts @@ -177,6 +177,7 @@ describe("release workflow quality gates", () => { "scripts/create-croco-app-generated-smoke-matrix.mts", "scripts/create-croco-app-generated-smoke.mts", "scripts/dependency-audit-policy.mts", + "scripts/first-success-generated-contract.mts", "scripts/first-success-verify.mts", "scripts/normalize-packages.mjs", "scripts/package-bin-smoke.mts", diff --git a/tsconfig/contract-strict.baseline.json b/tsconfig/contract-strict.baseline.json index 419617d41..1eb47c099 100644 --- a/tsconfig/contract-strict.baseline.json +++ b/tsconfig/contract-strict.baseline.json @@ -4696,7 +4696,7 @@ { "packageName": "create-croco-app", "file": "packages/create-croco-app/src/cli.ts", - "line": 57, + "line": 16, "column": 37, "code": "TS4111", "message": "Property 'json' comes from an index signature, so it must be accessed with ['json']." @@ -5007,9 +5007,9 @@ }, { "packageName": "create-croco-app", - "file": "packages/create-croco-app/src/types.ts", - "line": 1, - "column": 38, + "file": "packages/create-croco-app/src/saas-provider-profiles.ts", + "line": 8, + "column": 8, "code": "TS6059", "message": "File 'packages/tenant-core/src/tenant-model.ts' is not under 'rootDir' 'packages/create-croco-app/src'. 'rootDir' is expected to contain all source files." }