This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
LocalDevelopmentStack is a Kotlin CLI tool that scaffolds a local development environment. It has two modes:
- New service scaffold — given a service type and database, generates a complete runnable service from a template, plus
Dockerfile.devanddocker-compose.yml. The whole stack (DB + service) comes up with onedocker-compose up --build; hot-reload is enabled. - Existing service scaffold (
--existing-dir) — auto-detects the language in an existing directory, then generatesDockerfile.dev+docker-compose.ymlso the full local stack (service + database) runs withdocker-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.
| 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 |
--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.
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 stableDocker: Docker Desktop for Windows (requires WSL 2).
brew install temurin@17 gradle go python@3.12 node
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shDocker: Docker Desktop for Mac.
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 | shAll 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"
- Gradle wrapper is the canonical entry point, not a system
gradle.gradlew,gradlew.bat,gradle/wrapper/gradle-wrapper.jar, andgradle/wrapper/gradle-wrapper.propertiesare all committed. Don't delete them and don't replace withgradleinvocations in scripts/CI. .gitignorehas*.jarfollowed 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 verifygit check-ignore -v gradle/wrapper/gradle-wrapper.jarshows the negation winning before pushing..gitattributespinsgradlewto LF andgradle-wrapper.jarto binary. On Windows hosts withcore.autocrlf=true, removing these rules will corrupt the shebang on Linux runners (#!/bin/sh\r\n) or the JAR on commit.gradlewmust keep the executable bit in the git index (mode100755). On Windows-only checkouts, set it viagit update-index --chmod=+x gradlewwhenever the file is re-added.settings.gradle.ktsapplies the Foojay toolchain resolver. This auto-provisions JDK 17 (requested bybuild.gradle.kts:39'sjvmToolchain(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.
The tool is structured around five generator types:
ServiceGenerator(interface) — generates a complete new service project (new scaffold mode only)DatabaseGenerator(interface) — generatesdocker-compose.ymlwith the chosen database; optionally includes a service container block when aServiceComposeConfigis provided. Output contract: the file must end with\nvolumes:\n <name>_data:soappendMigrateBlockToComposeandappendCompanionBlocksToComposecan find their insertion point. Documented in theDatabaseGeneratorKDoc.DockerfileGenerator(abstract class, not interface) — generatesDockerfile.devfor an existing service (existing-dir mode only). The sharedgenerate()andDockerfile-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 onlyRubyDockerfileGenerator(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, optionalDockerfile.migrate) and amigrate:compose service block. Activated by--migration <tool>. Defaults to off.CompanionGenerator(interface) — generates an opt-in companion service (currentlymailhog,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 object — SERVICES: 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 |
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 |
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.
--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.
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, notruby:3.2-slim. Puma pullsnio4r, which has a C extension; slim lacksbuild-essential. Addingapt-get install build-essentialworks 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.
RubyDockerfileGeneratorchecksbin/railsorconfig/application.rband emitsbundle exec rails server -b 0.0.0.0 -p 8080for Rails apps,bundle exec ruby app.rbfor everything else (including the new-service scaffold, which is Sinatra). Don't collapse the two —ruby app.rbwon't start a Rails app andrails serverwon't start a Sinatra app. - rust: pre-built
cargo-watchtarball from GitHub releases, pinned atv8.5.2.cargo install cargo-watchcompiles from source for ~12 min on a cold build, well past any reasonable timeout. v8.5.3+ pullstoml_datetimecrates that requireedition2024(Rust 1.85+), incompatible with ourrust:1.75-slimbase. - php: Dockerfile installs
libpq-deveven when the target DB isn't Postgres.pdo_pgsqlis compiled in unconditionally so a single image works across all 8 supported databases;libpq-devis required at build time for the compile to succeed, andlibpq(runtime) is kept at runtime. - node:
npm install, notnpm ci.NodeServiceGeneratordoesn't write apackage-lock.json, sonpm ci(which requires one) fails. The Dockerfile copiespackage*.jsonso the lock file is picked up if a user later commits one —installworks in both cases. - dotnet:
projectNameis sanitized viatoCSharpNamespace()before being interpolated intoProgram.cs/ controllers. C# namespaces forbid hyphens (and identifiers can't start with a digit), so e.g.--name my-apibecomes namespacemy_api. The.csprojfilename keeps the original hyphenated form. - php / ruby use minimal frameworks deliberately. PHP uses the built-in CLI server (
php -S) with a tinypublic/index.phprouter; Ruby uses Sinatra + Puma. Laravel / Rails scaffolds were tried and dropped — both required heavycomposer install/bundle installfor native deps and broke the "singledocker compose upboots 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-latestmoved the tools tomssql-tools18/; the oldmssql-tools/path returns "command not found" and the healthcheck never goes Healthy, blockingdepends_on: condition: service_healthy. The-Cflag (trust server cert) is required because the 2022 image ships a self-signed cert by default.
--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.
- Manual run model. The
migrate:service uses composeprofiles: ["migrations"]so it does NOT auto-start ondocker-compose up. Users invoke it explicitly:docker-compose run --rm migrate. The user's service block is unchanged — nodepends_on: migrateis injected. - Post-processing, not interface extension.
MigrationGenerator.composeServiceBlock()returns a YAML snippet;appendMigrateBlockToCompose()(inMigrationComposeAppender.kt) reads the compose file the DB generator just wrote, splits on\nvolumes:\n, and inserts the migrate block above it. This keepsDatabaseGeneratorand 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 aDbConnectionInfo(jdbc URL, mongo URI, user, password) that migration generators consume. JDBC URLs and credentials live next todbEnvVars()so per-DB knowledge is in one file. Migration generators never build URLs themselves. - Existing-dir collision protection. When
--migrationis set in existing-dir mode, the CLI aborts if<dir>/migrations/,<dir>/db/changelog/, or<dir>/Dockerfile.migrateexist with content.--forceoverrides. The user's existing migrations almost certainly contain real ordered files; silently writingV001__init.sqlcould land between or before their numbered files and break their schema. - Name collision protection.
--name migrateand--name dbare rejected when migrations are enabled (would collide with themigrate:/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 onservice_healthyalone.
--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 |
- Post-processing, not interface change.
CompanionGenerator.composeServiceBlock()returns a YAML snippet;appendCompanionBlocksToCompose()(inCompanionComposeAppender.kt) splits the DB-written compose on\nvolumes:\nand inserts each block above. Named volumes declared viaCompanionGenerator.namedVolumes()are appended inside the existingvolumes:mapping after the DB's<name>_data:entry.DatabaseGeneratorand 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 ofdbEnvVars()and each companion'senvOverlay()before building theServiceComposeConfig. The composeenvironment:block lists keys;EnvFileGeneratorwrites the resolved values into.env. --withis the dispatch flag,COMPANIONSis the registry. Same pattern asSERVICES/DATABASES. Adding a companion is one map entry; the supported-list error message is auto-derived fromCOMPANIONS.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 existingvolumes:mapping, not as a new section.MinioCompanionGeneratoris the only companion with a named volume today; future companions should follow the same pattern. - Env overlay precedence is companion-wins, with a warning.
mergedEnvVarslogs a WARNING when a companion'senvOverlay()key collides with the DB's env var (none today; reserved for future companions). Don't silently shadow without the log.
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/.... Themailhog/mailhog:v1.0.1image is builtFROM golang:alpine; the shell is busyboxash, which does NOT implement/dev/tcp. The previously-tried["CMD", "sh", "-c", "echo > /dev/tcp/localhost/1025"]form left the container reportedunhealthyindefinitely. 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 theRELEASE.2024-12-18T13-15-44Zimage.curlis present in the MinIO image since the 2023 releases. - MinIO root credentials are baked into the compose YAML, not
.env-interpolated. Same pattern asPOSTGRES_PASSWORD/SA_PASSWORDfor 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.
ServiceComposeConfig.appendServiceBlock()writes each environment entry as- $KEY=\${$KEY}(compose-time substitution), never as the literal value. The resolved values live in.envnext 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;.envis added to.gitignore..envvalues are quoted when they contain shell-sensitive characters.EnvFileGenerator.quoteEnvValue()wraps values containing any of' \t#"'\!in"…"with backslash-escapes. Reason: a user who runssource .envin bash would otherwise hit history expansion on!(SQL Server'sDevOnly_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.envif missing and leaves user rules intact. The check is idempotent — repeated runs do not duplicate the entry.
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: 60sThe 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 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-runalso skips the Docker availability probe (run()short-circuitscheckDockerAvailable()whendryRunis 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 byMigrationGenerator.toolName(flyway→V001__init.sql,liquibase→db/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 thewhenbranch inprintDryRunPlanwhenever you change scaffold output.
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 viagradle run,java -jar, or the GraalVM native binary. - Size cap.
SizeCappedFileHandlertruncates the file in place when it reaches 10 MB. No.0/.1rotation suffixes — total disk usage stays ≤10 MB.LoggingTestexercises 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 addlog.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"), notLogger.getLogger(javaClass.name).LineFormatterstrips dotted prefixes anyway as a belt-and-braces guard so package paths never appear in the log. - Tests do not produce logs. Tests instantiate
LocalDevStackClidirectly (bypassingMain.kt), soLogging.init()is never called fromgradle test. New CLI-level tests should follow the same pattern. - Timestamp formatter is
java.time.DateTimeFormatter(thread-safe), notSimpleDateFormat.Loggerinfrastructure can publish from background threads (e.g. uncaught-exception handler);SimpleDateFormatis the classic JVM-logging race-condition source. Do not "simplify" by reverting.
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.
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.
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)
- Implement
ServiceGenerator(includeoverride val runCommand). - Subclass
DockerfileGenerator— overrideprotected fun dockerfile(): Stringonly. The base class handlesgenerate(), the existing-Dockerfilewarning, and the file path. Do NOT re-implementgenerate(). - Add one entry to
SERVICESinLocalDevStackCli's companion object:"<type>" to ServiceSpec(::YourServiceGenerator, ::YourDockerfileGenerator, listOf(".:/app", ...)). The volumes list is per-type (useDEFAULT_VOLUMESfor the default[".:/app"]). The supported-types help string is auto-derived fromSERVICES.keys— do not edit it separately. - Add parameterized test entries in
AllServiceGeneratorsTestandAllDockerfileGeneratorsTest.
- Implement
DatabaseGenerator. Output contract: the YAML must end with\nvolumes:\n <name>_data:soappendMigrateBlockToComposecan find its insertion point —MigrationComposeAppenderTestverifies this andMigrationComposeAppenderlogs a WARNING if a future generator drops the contract. - Add one entry to
DATABASESin 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 separatedbEnvVars/dbConnectionInfowhenblock to update. - 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. - Compose-YAML
$$escape. docker-compose treats$VARand${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_PASSWORDwrites\$\$SA_PASSWORDin the generator string (seeSqlServerDatabaseGenerator). A single\$followed by${...}is a Kotlin string-template parse error.
- Implement
CompanionGeneratorinsrc/main/kotlin/com/localdevstack/generator/. Required:companionName(lowercase identifier, also the compose service name and--withtoken),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 undervolumes:). - Add one entry to
COMPANIONSinLocalDevStackCli's companion object:"<name>" to CompanionSpec(::YourCompanionGenerator). The--withsupported-list and the name-collision check are auto-derived fromCOMPANIONS.keys. - Add entries in
AllCompanionGeneratorsTest(parameterized invariants: header line, trailing newline, uppercase env keys) and a CLI-level test inLocalDevStackCliTestmirroring the existing--with mailhog/--with miniotests. - 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
--withflag.
- Implement
MigrationGenerator—toolName,generateScaffold(outputDir, db, projectName),composeServiceBlock(db)returning the YAML snippet (must includeprofiles: ["migrations"],restart: "no", anddepends_on.db.condition: service_healthy), andcreateMigrationHint()for the "Next steps" output. - For SQL-based tools, reuse
identityColumnSql(databaseType)fromMigrationSqlHelpers.ktrather than re-writing the dialectwhen(Postgres/CockroachDB →SERIAL, SQL Server →IDENTITY(1,1), else →AUTO_INCREMENT). - Add a
whenbranch inLocalDevStackCli.resolveMigrationGenerator()(the inner factorywhen, not the supported-map check). - Add the tool to
SUPPORTED_MIGRATIONS(companion object) under each compatible database. - If the tool needs a custom image (e.g. migrate-mongo's npm install), generate a
Dockerfile.migratefromgenerateScaffoldand havecomposeServiceBlockreference it viabuild:. - Add per-tool unit test + entries in
AllMigrationGeneratorsTest(parameterized invariants) + valid/invalid combo entries inLocalDevStackCliTest.