Skip to content

Latest commit

 

History

History
392 lines (290 loc) · 35.8 KB

File metadata and controls

392 lines (290 loc) · 35.8 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

LocalDevelopmentStack is a Kotlin CLI tool that scaffolds a local development environment. It has two modes:

  1. New service scaffold — given a service type and database, generates a complete runnable service from a template, plus Dockerfile.dev and docker-compose.yml. The whole stack (DB + service) comes up with one docker-compose up --build; hot-reload is enabled.
  2. Existing service scaffold (--existing-dir) — auto-detects the language in an existing directory, then generates Dockerfile.dev + docker-compose.yml so the full local stack (service + database) runs with docker-compose up --build. Hot-reload is enabled; source changes are picked up automatically without a rebuild.

Both modes support optional database-migration scaffolding via --migration <tool>, which adds an opt-in one-shot migrate: service to the generated compose file plus an example migration in the appropriate format for the tool.

Requirements

To build and run the utility itself

Dependency Version Purpose
JDK 17+ Compile and run the Kotlin CLI
Gradle 8+ Build the fat JAR, run the CLI
Docker 24+ Run the generated docker-compose.yml

Additional requirements per generated service type (new service scaffold mode)

--service Dependency Version
springboot JDK, Gradle 17+, 8+
go Go 1.22+
python Python, pip 3.10+
node Node.js, npm 18+
rust Rust (via rustup), Cargo 1.75+
dotnet .NET SDK 8+
java JDK, Maven 21+
php PHP, Composer 8.2+
ruby Ruby, Bundler 3.2+

In existing-service mode these are not required on the host — the service runs inside Docker.

Installing on Windows

winget install EclipseAdoptium.Temurin.17.JDK
winget install Gradle.Gradle
winget install GoLang.Go
winget install Python.Python.3.12
winget install OpenJS.NodeJS.LTS
winget install Rustlang.Rustup   # then: rustup default stable

Docker: Docker Desktop for Windows (requires WSL 2).

Installing on macOS

brew install temurin@17 gradle go python@3.12 node
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Docker: Docker Desktop for Mac.

Installing on Linux (apt)

sudo apt install -y temurin-17-jdk   # requires adoptium PPA
curl -s "https://get.sdkman.io" | bash && sdk install gradle
sudo apt install -y docker.io docker-compose-plugin
sudo apt install -y golang-go python3 python3-pip
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt install -y nodejs
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Commands

All commands use the Gradle wrapper (./gradlew on macOS/Linux, gradlew.bat on Windows) — the wrapper is committed to the repo and pins Gradle 8.11.1. Examples below use the POSIX form; substitute gradlew.bat on Windows.

Build a runnable fat JAR:

./gradlew shadowJar

Run directly via Gradle (new service scaffold, defaults):

./gradlew run

Run with explicit options:

./gradlew run --args="--service go --database postgres --output ./my-stack --name my-api"

Wrap an existing service directory:

./gradlew run --args="--existing-dir ./my-existing-service --database postgres"

Generate with migration scaffolding (Flyway example):

./gradlew run --args="--service go --database postgres --migration flyway --output ./my-stack --name my-api"

Run the built JAR:

java -jar build/libs/LocalDevelopmentStack-1.0.0.jar --help

Run the test suite:

./gradlew test

Run a single test class or method:

./gradlew test --tests "com.localdevstack.LocalDevStackCliTest"
./gradlew test --tests "com.localdevstack.LocalDevStackCliTest.removing migration tool on regenerate yields compose without migrate block"

Build environment invariants

  • Gradle wrapper is the canonical entry point, not a system gradle. gradlew, gradlew.bat, gradle/wrapper/gradle-wrapper.jar, and gradle/wrapper/gradle-wrapper.properties are all committed. Don't delete them and don't replace with gradle invocations in scripts/CI.
  • .gitignore has *.jar followed by !gradle/wrapper/gradle-wrapper.jar. If you regenerate the wrapper (e.g. gradle wrapper --gradle-version X), the JAR will look untracked until the negation catches it — don't drop the negation, and verify git check-ignore -v gradle/wrapper/gradle-wrapper.jar shows the negation winning before pushing.
  • .gitattributes pins gradlew to LF and gradle-wrapper.jar to binary. On Windows hosts with core.autocrlf=true, removing these rules will corrupt the shebang on Linux runners (#!/bin/sh\r\n) or the JAR on commit.
  • gradlew must keep the executable bit in the git index (mode 100755). On Windows-only checkouts, set it via git update-index --chmod=+x gradlew whenever the file is re-added.
  • settings.gradle.kts applies the Foojay toolchain resolver. This auto-provisions JDK 17 (requested by build.gradle.kts:39's jvmToolchain(17)) on hosts that only have a newer JDK installed — including the CI Windows runner, which only sets up GraalVM 21. Removing the plugin re-introduces "No locally installed toolchains match" failures.

Architecture

The tool is structured around five generator types:

  • ServiceGenerator (interface) — generates a complete new service project (new scaffold mode only)
  • DatabaseGenerator (interface) — generates docker-compose.yml with the chosen database; optionally includes a service container block when a ServiceComposeConfig is provided. Output contract: the file must end with \nvolumes:\n <name>_data: so appendMigrateBlockToCompose and appendCompanionBlocksToCompose can find their insertion point. Documented in the DatabaseGenerator KDoc.
  • DockerfileGenerator (abstract class, not interface) — generates Dockerfile.dev for an existing service (existing-dir mode only). The shared generate() and Dockerfile-already-exists warning live in the base class; subclasses override exactly one of two hooks. (a) protected open fun dockerfile(): String — the common case, used by 8 of 9 generators where the Dockerfile content is fixed; each subclass is ~12 lines. (b) protected open fun dockerfile(serviceDir: Path): String — used when the Dockerfile content depends on what exists in the target directory; currently only RubyDockerfileGenerator (Rails vs. Sinatra detection). The base class delegates (serviceDir) to () by default. Always single-stage with hot-reload tooling, never copies source (source is volume-mounted).
  • MigrationGenerator (interface) — generates migration scaffolding (example migration files, configs, optional Dockerfile.migrate) and a migrate: compose service block. Activated by --migration <tool>. Defaults to off.
  • CompanionGenerator (interface) — generates an opt-in companion service (currently mailhog, minio) plus environment-variable overlays for the user's service and optional named volumes. Activated by --with <name>[,<name>...]. Defaults to off.

Two small file-only generators run alongside these in every invocation: EnvFileGenerator writes .env + .env.example, and GitignoreGenerator writes (or idempotently appends to) .gitignore. They do not have a registry map because there's no per-language variation.

LocalDevStackCli (picocli @Command) dispatches via three registry maps in its companion objectSERVICES: Map<String, ServiceSpec>, DATABASES: Map<String, DbSpec>, and COMPANIONS: Map<String, CompanionSpec> — not parallel when blocks. Each entry bundles all per-type knowledge (factory functions, volumes/env-vars, connection info), so adding a new type is one map entry rather than edits across five resolvers. The companion object also holds DEFAULT_PROJECT_NAME, DEFAULT_DATABASE, DEFAULT_SERVICE_TYPE, DEFAULT_OUTPUT_DIR, PORT_CANDIDATES, DOCKER_PROBE_TIMEOUT_SECONDS, and the SUPPORTED_MIGRATIONS compatibility map — refer to these constants rather than hand-writing literals. Main.kt is the entry point.

Service generators (9 types)

--service Implementation Framework / Stack
springboot SpringBootServiceGenerator Kotlin + Spring Boot, Gradle
go GoServiceGenerator Go + net/http
python PythonServiceGenerator Python + FastAPI
node NodeServiceGenerator Node.js + Express
rust RustServiceGenerator Rust + Axum, Cargo
dotnet DotNetServiceGenerator C# + ASP.NET Core 8
java JavaServiceGenerator Java 21 + Spring Boot, Maven
php PhpServiceGenerator PHP 8.2 + built-in server
ruby RubyServiceGenerator Ruby 3.2 + Sinatra 4

All generated services expose GET /health{"status":"ok"}.

Database generators (8 types)

--database Implementation Image Port Injected env var
postgres PostgresDatabaseGenerator postgres:16 5432 DATABASE_URL
mysql MySqlDatabaseGenerator mysql:8 3306 DATABASE_URL
mongodb MongoDbDatabaseGenerator mongo:7 27017 MONGODB_URI
cockroachdb CockroachDbDatabaseGenerator cockroachdb/cockroach:v23.2.0 26257 DATABASE_URL
redis RedisDatabaseGenerator redis:7-alpine 6379 REDIS_URL
mariadb MariaDbDatabaseGenerator mariadb:11 3306 DATABASE_URL
sqlserver SqlServerDatabaseGenerator mcr.microsoft.com/mssql/server:2022-latest 1433 DATABASE_URL
elasticsearch ElasticsearchDatabaseGenerator elasticsearch:8.12 9200 ELASTICSEARCH_URL

All database services are named db: in the compose file so connection URLs use @db:PORT consistently.

Dockerfile generators (9 types, existing-dir mode only)

--service Implementation Hot-reload tool
springboot SpringBootDockerfileGenerator gradle bootRun
go GoDockerfileGenerator air (cosmtrek/air)
python PythonDockerfileGenerator uvicorn --reload
node NodeDockerfileGenerator nodemon
rust RustDockerfileGenerator cargo-watch
dotnet DotNetDockerfileGenerator dotnet watch run
java JavaDockerfileGenerator mvn spring-boot:run
php PhpDockerfileGenerator PHP built-in server (serves files on request)
ruby RubyDockerfileGenerator Rails dev server if bin/rails or config/application.rb is present; else Sinatra (ruby app.rb)

Dockerfile.dev is always single-stage. Source code is never copied (COPY . . is absent); it is volume-mounted at runtime via the compose volumes: block.

Per-service Dockerfile constraints (don't drift from these)

Confirmed by a 72-combo docker compose up --build-and-curl-/health sweep (one combo per service × database). The constraints below caused real failures in that sweep and the fixes are now baked into the generators — reverting any of them re-introduces the failure.

  • ruby: FROM ruby:3.2, not ruby:3.2-slim. Puma pulls nio4r, which has a C extension; slim lacks build-essential. Adding apt-get install build-essential works but the first-build apt-get on a slow Debian mirror exceeded a 15-min budget. Full image has the build chain preinstalled.
  • ruby: Rails vs Sinatra is detected from the existing-dir contents, not from a flag. RubyDockerfileGenerator checks bin/rails or config/application.rb and emits bundle exec rails server -b 0.0.0.0 -p 8080 for Rails apps, bundle exec ruby app.rb for everything else (including the new-service scaffold, which is Sinatra). Don't collapse the two — ruby app.rb won't start a Rails app and rails server won't start a Sinatra app.
  • rust: pre-built cargo-watch tarball from GitHub releases, pinned at v8.5.2. cargo install cargo-watch compiles from source for ~12 min on a cold build, well past any reasonable timeout. v8.5.3+ pulls toml_datetime crates that require edition2024 (Rust 1.85+), incompatible with our rust:1.75-slim base.
  • php: Dockerfile installs libpq-dev even when the target DB isn't Postgres. pdo_pgsql is compiled in unconditionally so a single image works across all 8 supported databases; libpq-dev is required at build time for the compile to succeed, and libpq (runtime) is kept at runtime.
  • node: npm install, not npm ci. NodeServiceGenerator doesn't write a package-lock.json, so npm ci (which requires one) fails. The Dockerfile copies package*.json so the lock file is picked up if a user later commits one — install works in both cases.
  • dotnet: projectName is sanitized via toCSharpNamespace() before being interpolated into Program.cs / controllers. C# namespaces forbid hyphens (and identifiers can't start with a digit), so e.g. --name my-api becomes namespace my_api. The .csproj filename keeps the original hyphenated form.
  • php / ruby use minimal frameworks deliberately. PHP uses the built-in CLI server (php -S) with a tiny public/index.php router; Ruby uses Sinatra + Puma. Laravel / Rails scaffolds were tried and dropped — both required heavy composer install / bundle install for native deps and broke the "single docker compose up boots a healthy /health" invariant under reasonable timeouts. Don't "upgrade" these to full frameworks without re-running the sweep.
  • SQL Server healthcheck uses /opt/mssql-tools18/bin/sqlcmd. mcr.microsoft.com/mssql/server:2022-latest moved the tools to mssql-tools18/; the old mssql-tools/ path returns "command not found" and the healthcheck never goes Healthy, blocking depends_on: condition: service_healthy. The -C flag (trust server cert) is required because the 2022 image ships a self-signed cert by default.

Migration generators (4 tools)

--migration Implementation Image Compatible databases
flyway FlywayMigrationGenerator flyway/flyway:10 postgres, mysql, mariadb, sqlserver, cockroachdb
liquibase LiquibaseMigrationGenerator liquibase/liquibase:4.27 postgres, mysql, mariadb, sqlserver (NOT cockroachdb — driver extension missing from stock image)
migrate-mongo MigrateMongoMigrationGenerator built from Dockerfile.migrate (FROM node:20-alpine, pinned migrate-mongo@11.0.0) mongodb
golang-migrate GolangMigrateMigrationGenerator migrate/migrate:v4.17.1 postgres, mysql, mariadb, cockroachdb, sqlserver, mongodb

Compatibility validation is centralized in LocalDevStackCli.resolveMigrationGenerator() — invalid (database, tool) pairs and redis/elasticsearch (no migration support) are rejected with a clear error before any files are written.

Migration architecture decisions (don't drift from these)

  • Manual run model. The migrate: service uses compose profiles: ["migrations"] so it does NOT auto-start on docker-compose up. Users invoke it explicitly: docker-compose run --rm migrate. The user's service block is unchanged — no depends_on: migrate is injected.
  • Post-processing, not interface extension. MigrationGenerator.composeServiceBlock() returns a YAML snippet; appendMigrateBlockToCompose() (in MigrationComposeAppender.kt) reads the compose file the DB generator just wrote, splits on \nvolumes:\n, and inserts the migrate block above it. This keeps DatabaseGenerator and all 8 DB implementations untouched. Default-mode output (no --migration) is byte-identical to before the feature was added — there is a parameterized regression test guarding this for every DB.
  • Centralized DB connection facts. LocalDevStackCli.dbConnectionInfo(type) returns a DbConnectionInfo (jdbc URL, mongo URI, user, password) that migration generators consume. JDBC URLs and credentials live next to dbEnvVars() so per-DB knowledge is in one file. Migration generators never build URLs themselves.
  • Existing-dir collision protection. When --migration is set in existing-dir mode, the CLI aborts if <dir>/migrations/, <dir>/db/changelog/, or <dir>/Dockerfile.migrate exist with content. --force overrides. The user's existing migrations almost certainly contain real ordered files; silently writing V001__init.sql could land between or before their numbered files and break their schema.
  • Name collision protection. --name migrate and --name db are rejected when migrations are enabled (would collide with the migrate: / db: compose service names).
  • Pinning. All migration tool images are pinned at major+minor (matching project convention for DB images). The migrate-mongo npm package is strictly version-pinned.
  • Connection retries. Tools that support it include retry flags (e.g. Flyway -connectRetries=60). Healthchecks can fire mid-init; never rely on service_healthy alone.

Companion services (2 entries)

--with Implementation Image Ports Env vars injected into user service Named volumes
mailhog MailhogCompanionGenerator mailhog/mailhog:v1.0.1 1025, 8025 SMTP_HOST, SMTP_PORT (none)
minio MinioCompanionGenerator minio/minio:RELEASE.2024-12-18T13-15-44Z 9000, 9001 S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY minio_data

Companion architecture decisions (don't drift from these)

  • Post-processing, not interface change. CompanionGenerator.composeServiceBlock() returns a YAML snippet; appendCompanionBlocksToCompose() (in CompanionComposeAppender.kt) splits the DB-written compose on \nvolumes:\n and inserts each block above. Named volumes declared via CompanionGenerator.namedVolumes() are appended inside the existing volumes: mapping after the DB's <name>_data: entry. DatabaseGenerator and all 8 DB implementations remain untouched. Default mode (no --with) must produce byte-identical compose output — there is a parameterized regression test (CompanionComposeAppenderTest) covering every DB.
  • Env overlay is merged in the CLI, not in the DB generator. LocalDevStackCli.mergedEnvVars(databaseType, companionGenerators) returns the union of dbEnvVars() and each companion's envOverlay() before building the ServiceComposeConfig. The compose environment: block lists keys; EnvFileGenerator writes the resolved values into .env.
  • --with is the dispatch flag, COMPANIONS is the registry. Same pattern as SERVICES / DATABASES. Adding a companion is one map entry; the supported-list error message is auto-derived from COMPANIONS.keys.
  • Name collision protection. When companions are active, validateNameForCompanions() rejects --name mailhog / --name minio (would collide with the companion's compose service name). Same pattern as the migrate/db rejection.
  • Per-companion volume contract extension. The DB-output contract \nvolumes:\n <name>_data: is unchanged — companion volumes are appended inside the existing volumes: mapping, not as a new section. MinioCompanionGenerator is the only companion with a named volume today; future companions should follow the same pattern.
  • Env overlay precedence is companion-wins, with a warning. mergedEnvVars logs a WARNING when a companion's envOverlay() key collides with the DB's env var (none today; reserved for future companions). Don't silently shadow without the log.

Per-companion constraints (don't drift from these)

Confirmed by the 19-combo extended sweep (9 services × --with mailhog + 9 × --with minio + 1 omnibus). Same lifetime promise as the per-service Dockerfile constraints: reverting any of these re-introduces a real failure.

  • mailhog healthcheck must use wget --spider, not /dev/tcp/.... The mailhog/mailhog:v1.0.1 image is built FROM golang:alpine; the shell is busybox ash, which does NOT implement /dev/tcp. The previously-tried ["CMD", "sh", "-c", "echo > /dev/tcp/localhost/1025"] form left the container reported unhealthy indefinitely. The fix: ["CMD-SHELL", "wget -q --spider http://localhost:8025/ || exit 1"] — wget is in busybox and the HTTP UI port is the most reliable probe.
  • minio healthcheck uses /minio/health/live, not /health. That's MinIO's documented liveness endpoint for the RELEASE.2024-12-18T13-15-44Z image. curl is present in the MinIO image since the 2023 releases.
  • MinIO root credentials are baked into the compose YAML, not .env-interpolated. Same pattern as POSTGRES_PASSWORD/SA_PASSWORD for DB generators: infrastructure-service credentials are inline, only the user-service env vars go through .env. Don't refactor MinIO to read from .env — it would diverge from the DB-generator convention without benefit.

Compose interpolation + .env flow

  • ServiceComposeConfig.appendServiceBlock() writes each environment entry as - $KEY=\${$KEY} (compose-time substitution), never as the literal value. The resolved values live in .env next to the compose file.
  • EnvFileGenerator.generate(outputDir, envVars) writes both .env (with resolved values) and .env.example (with <change-me> placeholders). Both files are emitted in every invocation; .env is added to .gitignore.
  • .env values are quoted when they contain shell-sensitive characters. EnvFileGenerator.quoteEnvValue() wraps values containing any of ' \t#"'\! in "…" with backslash-escapes. Reason: a user who runs source .env in bash would otherwise hit history expansion on ! (SQL Server's DevOnly_123! password) or word-splitting on spaces. Common URLs (postgres / mongo / redis) stay unquoted because they contain none of these. Docker Compose's dotenv parser is fine either way.
  • GitignoreGenerator.generate(outputDir) writes a fresh .gitignore (containing .env + *.local) in new-scaffold mode; in existing-dir mode it appends just .env if missing and leaves user rules intact. The check is idempotent — repeated runs do not duplicate the entry.

Service healthcheck stanza

Every service block emitted by appendServiceBlock() now includes a healthcheck stanza:

healthcheck:
  test: ["CMD-SHELL", "wget -q -O- http://localhost:<port>/health || curl -fsS http://localhost:<port>/health || exit 1"]
  interval: 10s
  timeout: 3s
  retries: 6
  start_period: 60s

The wget-then-curl fallback covers every base image used by the 9 service generators (alpine busybox provides wget; slim/SDK debian images ship curl). start_period: 60s accommodates Spring Boot / .NET startup. This enables depends_on: service_healthy from companions or downstream containers.

Dry-run

--dry-run returns early after planning, before any generator is invoked. It prints the resolved plan plus a list of files that would be written, computed from the CLI's own knowledge — no FileWriter abstraction or generator changes were introduced for this. The dry-run path is exercised by parameterized tests asserting zero files in tempDir for every service × DB combo.

  • --dry-run also skips the Docker availability probe (run() short-circuits checkDockerAvailable() when dryRun is set). Reason: dry-run is the documented "preview without writing" mode and must work on hosts where Docker is unavailable (CI runners that only validate generation, doc-build machines). Keep this skip — don't reinstate the probe.
  • The migration scaffold file list in printDryRunPlan() is hardcoded by MigrationGenerator.toolName (flywayV001__init.sql, liquibasedb/changelog/db.changelog-master.sql, etc.). Acceptable drift risk per the senior code review: if a future migration generator adds a second scaffold file, the dry-run list will silently omit it — add the new file to the when branch in printDryRunPlan whenever you change scaffold output.

Logging (Logging.kt)

Main.kt calls Logging.init() once at startup; LocalDevStackCli calls Logging.named("LocalDevStackCli").info(...) at decision points. Why java.util.logging and not Logback/SLF4J: zero new runtime deps and no --initialize-at-build-time / reflect-config entries needed for gradle nativeCompile.

  • Output. ./logs/localdevstack.log, relative to invocation CWD. Same path whether invoked via gradle run, java -jar, or the GraalVM native binary.
  • Size cap. SizeCappedFileHandler truncates the file in place when it reaches 10 MB. No .0/.1 rotation suffixes — total disk usage stays ≤10 MB. LoggingTest exercises this with a 1 KB cap.
  • Two channels, do not collapse them. Console (println / System.err.println) is the user-facing channel and stays untouched — adding a feature must NOT replace those calls with log calls. The file is for triage detail (mode dispatch, generator selection, validation rejections, full stack traces).
  • Stack traces are file-only. Pattern: keep the existing System.err.println(e.message) and add log.log(Level.WARNING, "<context>", e) alongside it. Never echo a stack trace to the console.
  • Logger names are short semantic strings. Use Logging.named("LocalDevStackCli"), not Logger.getLogger(javaClass.name). LineFormatter strips dotted prefixes anyway as a belt-and-braces guard so package paths never appear in the log.
  • Tests do not produce logs. Tests instantiate LocalDevStackCli directly (bypassing Main.kt), so Logging.init() is never called from gradle test. New CLI-level tests should follow the same pattern.
  • Timestamp formatter is java.time.DateTimeFormatter (thread-safe), not SimpleDateFormat. Logger infrastructure can publish from background threads (e.g. uncaught-exception handler); SimpleDateFormat is the classic JVM-logging race-condition source. Do not "simplify" by reverting.

Testing setup

tasks.test in build.gradle.kts sets java.io.tmpdir to build/test-tmp/. Do not remove this. LocalDevStackCli.runNewServiceMode() rejects any outputDir outside the JVM's CWD as a safety check against typos like --output /etc. JUnit @TempDir defaults to the OS temp dir, which would fail that check. Pointing java.io.tmpdir inside the project tree makes every @TempDir land in a path that satisfies the production validation, so tests don't need a special bypass flag.

Tests use LocalDevStackCli().apply { skipDockerCheck = true; outputDir = tempDir.toString(); ... } — direct property assignment, not picocli parsing. skipDockerCheck is the only test-only seam in production code.

Language detection (ExistingServiceDetector)

Detects service type from root-level sentinel files:

Sentinel file(s) Detected type
go.mod go
Cargo.toml rust
build.gradle.kts / build.gradle springboot
pom.xml java
Program.cs / *.csproj dotnet
Gemfile ruby
composer.json php
package.json node
requirements.txt / pyproject.toml python

Multiple distinct types → DetectionException with explicit --service override examples.

Generated output structure

Both modes produce a stack that comes up with a single docker-compose up --build — DB + service container with hot-reload via volume-mounted source. The only difference is whether the source is generated from a template (new) or pre-existing (existing-dir). New-scaffold mode passes a ServiceComposeConfig with buildContext = "./service" so the compose build: block points at the subdirectory; existing-dir keeps the default buildContext = ".".

New service scaffold:

<output>/
├── service/
│   ├── <language-specific source files>
│   │   └── GET /health → {"status":"ok"}
│   └── Dockerfile.dev    # single-stage, hot-reload, no COPY . .
└── docker-compose.yml    # database + your service container (volume-mounted source)

Existing service scaffold:

<existing-dir>/
├── <your source files — untouched>
├── Dockerfile.dev        # single-stage, hot-reload, no COPY . .
└── docker-compose.yml    # database + your service container (volume-mounted source)

With --migration <tool> (added on top of either mode above):

├── migrations/                       # flyway, golang-migrate, migrate-mongo
│   └── V001__init.sql / 000001_init.up.sql / 0001-init.js
├── db/changelog/                     # liquibase only
│   └── db.changelog-master.sql
├── Dockerfile.migrate                # migrate-mongo only (caches npm install)
├── migrate-mongo-config.js           # migrate-mongo only
└── docker-compose.yml                # +migrate: service (profiles: ["migrations"])

Always emitted (in every invocation, both modes):

├── .env                              # resolved env vars (gitignored)
├── .env.example                      # placeholder env vars (safe to commit)
└── .gitignore                        # fresh in new-scaffold; appended in existing-dir

With --with <companion>[,<name>...] (added on top of either mode above):

└── docker-compose.yml                # +mailhog: and/or +minio: service blocks
                                      # (named volume `minio_data:` added when minio is used)

Adding a new service type

  1. Implement ServiceGenerator (include override val runCommand).
  2. Subclass DockerfileGenerator — override protected fun dockerfile(): String only. The base class handles generate(), the existing-Dockerfile warning, and the file path. Do NOT re-implement generate().
  3. Add one entry to SERVICES in LocalDevStackCli's companion object: "<type>" to ServiceSpec(::YourServiceGenerator, ::YourDockerfileGenerator, listOf(".:/app", ...)). The volumes list is per-type (use DEFAULT_VOLUMES for the default [".:/app"]). The supported-types help string is auto-derived from SERVICES.keys — do not edit it separately.
  4. Add parameterized test entries in AllServiceGeneratorsTest and AllDockerfileGeneratorsTest.

Adding a new database type

  1. Implement DatabaseGenerator. Output contract: the YAML must end with \nvolumes:\n <name>_data: so appendMigrateBlockToCompose can find its insertion point — MigrationComposeAppenderTest verifies this and MigrationComposeAppender logs a WARNING if a future generator drops the contract.
  2. Add one entry to DATABASES in the companion object: "<type>" to DbSpec(::YourDatabaseGenerator, mapOf("<ENV_KEY>" to "<url>"), { DbConnectionInfo(it, jdbcUrl = "...", user = "...", password = "...") }). JDBC URL, env var, and credentials all live in this single entry — there is no separate dbEnvVars/dbConnectionInfo when block to update.
  3. Add the new DB to SUPPORTED_MIGRATIONS (with the compatible tools list, or empty list for "no migration support"). The "supported migration databases" error string is auto-derived from this map.
  4. Compose-YAML $$ escape. docker-compose treats $VAR and ${VAR} as compose-time substitution and $$VAR / $${VAR} as a literal pass-through to the container shell. In Kotlin source the literal $ must itself be escaped, so a healthcheck that wants the container's shell to read $SA_PASSWORD writes \$\$SA_PASSWORD in the generator string (see SqlServerDatabaseGenerator). A single \$ followed by ${...} is a Kotlin string-template parse error.

Adding a new companion type

  1. Implement CompanionGenerator in src/main/kotlin/com/localdevstack/generator/. Required: companionName (lowercase identifier, also the compose service name and --with token), composeServiceBlock() returning a YAML snippet starting with <name>: and ending in a newline. Optional overrides: envOverlay() (env vars merged into the user's service), namedVolumes() (entries appended under volumes:).
  2. Add one entry to COMPANIONS in LocalDevStackCli's companion object: "<name>" to CompanionSpec(::YourCompanionGenerator). The --with supported-list and the name-collision check are auto-derived from COMPANIONS.keys.
  3. Add entries in AllCompanionGeneratorsTest (parameterized invariants: header line, trailing newline, uppercase env keys) and a CLI-level test in LocalDevStackCliTest mirroring the existing --with mailhog / --with minio tests.
  4. If the companion is heavy / slow / non-universal, score it against the five criteria documented in the brainstorm before adding: universal need across personas, zero-config single container, drop-in for a real cloud service, visible UI, mature stable image. Companions that fail criterion #2 (needs init / unseal / config) belong in a separate larger feature, not the --with flag.

Adding a new migration tool

  1. Implement MigrationGeneratortoolName, generateScaffold(outputDir, db, projectName), composeServiceBlock(db) returning the YAML snippet (must include profiles: ["migrations"], restart: "no", and depends_on.db.condition: service_healthy), and createMigrationHint() for the "Next steps" output.
  2. For SQL-based tools, reuse identityColumnSql(databaseType) from MigrationSqlHelpers.kt rather than re-writing the dialect when (Postgres/CockroachDB → SERIAL, SQL Server → IDENTITY(1,1), else → AUTO_INCREMENT).
  3. Add a when branch in LocalDevStackCli.resolveMigrationGenerator() (the inner factory when, not the supported-map check).
  4. Add the tool to SUPPORTED_MIGRATIONS (companion object) under each compatible database.
  5. If the tool needs a custom image (e.g. migrate-mongo's npm install), generate a Dockerfile.migrate from generateScaffold and have composeServiceBlock reference it via build:.
  6. Add per-tool unit test + entries in AllMigrationGeneratorsTest (parameterized invariants) + valid/invalid combo entries in LocalDevStackCliTest.