Scripts
scala-cli scripts under scripts/ for project lifecycle tasks. Each is self-contained — its //> using directives declare deps, scala-cli does the rest. Run them either as executables (./scripts/X.scala …) or via scala-cli (scala-cli run scripts/X.scala -- …).
init-project.scala
Section titled “init-project.scala”Use this once, right after cloning the template, to turn it into your project. Renames madrileno everywhere, swaps the Scala package, drops the auction showcase domain, removes the template’s Apache-2.0 LICENSE (generated projects may relicense freely — see the README’s License section), pins the upstream sha for the MCP server, and (by default) deletes the local docs/ tree since the MCP serves them from the pinned ref.
./scripts/init-project.scala wine-cellar./scripts/init-project.scala wine-cellar --package winecellar./scripts/init-project.scala wine-cellar --keep-docs # keep `docs/` locallyIf you don’t pass --package, the package is the project name lowercased with non-alphanumerics stripped (wine-cellar → winecellar).
What it does, in order:
- Sanity checks — refuses to run if
build.sbtisn’t present, ifsrc/main/scala/madrileno/is missing (already renamed), or ifgit rev-parse HEADcan’t resolve a sha (init must run from a git clone — the sha gets pinned in.madrileno-ref). - Deletes the auction demo —
src/{main,test}/scala/madrileno/auction/and any Flyway migration whose filename mentionsauctionorbid. - Deletes
LICENSEand the README’s License section — both describe the template, not your project. The README section explicitly grants projects generated via this script the right to relicense with no attribution required, so add whatever license fits yours. - Rewrites file contents under every text file outside
.git,target, IDE caches,node_modules, andscripts/:- Drops blocks bracketed by
// scripts:auction-block-start/// scripts:auction-block-end— auction-coupled fragments in test support that can’t be deleted as whole files (today: two inTestData.scala, one inTestApplicationLoader.scala, one inSchedulerAdminRouterSpec.scala). The markers must occupy their own line — inline mentions in prose are ignored. - Drops
import madrileno.auction.*lines andwith AuctionModulelines from the loaders’extendschains. - Renames
madrileno.<X>package references to<package>.<X>. - Renames standalone
madrilenoto<project-name>(HOCONapp.name, container names,OTEL_SERVICE_NAME,PG_DATABASE,MAILER_FROM_ADDRESS, README/doc references, etc.). - Skips
docs/mcp.md— that file documents the MCP system itself; itsmadrileno_*tool names, the upstream repo URL, and the upstream path examples are intentional and must not be renamed.
- Drops blocks bracketed by
- Renames the source directories —
src/{main,test}/scala/madrileno/→src/{main,test}/scala/<package>/. - Writes
.madrileno-refwith the upstream URL (derived fromgit remote get-url origin, falling back to the canonical madrileno URL) + the sha resolved at step 1. The MCP server reads this to anchor every tool call to a specific upstream commit. Commit it — your collaborators (and Claude) benefit from a shared pin. - Updates
.gitignore— whitelists.madrileno-ref(the template’s blanket.*rule would otherwise hide it) and adds.madrileno-mcp/(the shadow clone the MCP server keeps for serving docs/source). - Deletes
docs/unless--keep-docs. The MCP server serves docs on demand from the pinned ref; most projects don’t need them in-tree. - Rewrites
docs/<X>link targets across every*.mdin the project tree (outside.git,target, IDE caches,node_modules,scripts/) to point at upstream at the pinned sha (e.g. README’s reference-section links becomehttps://github.com/madrileno-dev/madrileno/blob/<sha>/docs/<X>). Matches any link target starting withdocs/—.md, images, anything. Only runs when docs were deleted, so the links stay clickable. Under--keep-docsthe links stay local.
After running:
cp .env.sample .envsbt testFullThe init script runs sbt 'scalafixAll; scalafixAll' itself before printing the next-steps, because the auction surgery leaves a pile of imports that were used only by the now-deleted methods (e.g. org.http4s.MediaType, madrileno.utils.imaging.*). Two passes because removing one import can free another. If that step fails (e.g., sbt not on PATH), re-run it manually before sbt testFull.
If anything’s off, git checkout . reverts.
About the marker convention
Section titled “About the marker convention”// scripts:auction-block-start … // scripts:auction-block-end flags a region that’s coupled to the auction demo but can’t be deleted as a standalone file (e.g., one method inside a shared TestData.scala). The markers are just comments — they have no effect at compile time and live in the source repo permanently. init-project.scala deletes whatever’s between them (markers included) when it runs.
Adding a new auction-coupled block elsewhere? Bracket it with the same markers and the init script picks it up automatically. Be conservative — every marker is a hard-coded “this is auction-specific, drop on init” declaration that has to stay accurate.
What it doesn’t do
Section titled “What it doesn’t do”- Doesn’t touch your git working tree (no
git add/git commit). Rungit statusafter, review, commit yourself. - Doesn’t run
sbt compile. The compiler is the safety net; running it is your call. (It does runsbt scalafixAlltwice, to clear orphaned imports from the auction surgery — see above.) - Doesn’t rewrite prose. Surviving doc files (under
--keep-docs) get themadrileno→<name>substitution, butREADME.md, badges, and any project-specific marketing copy are yours to rewrite. Same fordocs/architecture.md-style “why we did X” notes — they describe the template’s reasoning and might or might not match your own. - Doesn’t delete itself. Once you’re done renaming,
rm scripts/init-project.scala(and this doc, if you want) — the template baggage is yours to keep or trim.
scaffold-module.scala
Section titled “scaffold-module.scala”Generate a new module (aggregate vertical slice) under the project’s package — domain, repository, service, router, DTO, module trait, Flyway migration, and three specs (domain, repository, router). Wires the module into ApplicationLoader’s extends chain and injects a random<Aggregate>Id() factory into the shared TestData object, both automatically.
./scripts/scaffold-module.scala Wine winesTwo positional arguments:
- Aggregate — PascalCase singular, drives class names:
Wine,WineId,WineRepository,WineModule, etc. - Plural — lowercase plural, drives URL segments (
/v1/wines/{id}) and OpenAPI tags (tags = Seq("Wines")).
The script derives the singular lowercase variant from the aggregate (Wine → wine) and uses it for the module subpackage name, variable names, the SQL table name (wine), and the migration filename (V<N>__wine.sql). This matches upstream’s convention — V1__user_auth.sql, V5__auction.sql, etc. all use singular table names. The project’s root package is auto-detected from the single directory under src/main/scala/.
What it does:
- Sanity checks — refuses to run if not in a project root (no
build.sbt/ nosrc/main/scala/), if the project package can’t be uniquely identified (must be exactly one directory undersrc/main/scala/), if the templates dir (scripts/templates/module/) is missing, if the migration dir (src/main/resources/db/migration/) is missing, ifApplicationLoader.scalais missing or doesn’t contain the expectedHealthCheckModuleanchors, if either target dir (mainDest,testDest) already exists, if a<Aggregate>Module.scalaalready exists anywhere undersrc/main/scala/(class-name collision with an existing module — e.g.HealthCheck health_checkswould clash with the built-inHealthCheckModule), or if any existing migration already creates a table with the singular’s name (e.g.Auction auctionswhen an earlier migration already createsauction). All checks run before any writes, so a failing precondition leaves the working tree untouched. - Copies the template tree with placeholder substitution. Files under
scripts/templates/module/main/go tosrc/main/scala/<package>/<aggregate>/;test/goes tosrc/test/scala/<package>/<aggregate>/;migration/files becomeV<next>__<name>.sqlundersrc/main/resources/db/migration/, where<next>is the highest existingV<N>plus one. - Auto-wires by inserting both
import <package>.<aggregate>.<Aggregate>Moduleandwith <Aggregate>ModuleintoApplicationLoader.scala, anchored on theHealthCheckModuleimport andwithclauses (always present in the framework’s stock loader). Imports are not sorted alphabetically at insertion —sbt scalafixAll(recommended in the next-steps printout) reorders them. - Injects the test-data id factory into
src/test/scala/<package>/support/TestData.scala— bothimport <package>.<aggregate>.domain.<Aggregate>Idand arandom<Aggregate>Id(): <Aggregate>Id = <Aggregate>Id(randomUuid())line, anchored on theUuidV7import and the// scripts:scaffold-id-factoriesmarker. The import is targeted at<Aggregate>Idrather than a domain wildcard, so scaffolding a second module can’t introduce an ambiguous name intoTestData. The generated specs callTestData.random<Aggregate>Id()instead of minting raw UUIDs, so the output passes thenoRandomUuidscalafix lint (which bansUUID.randomUUIDin tests too) out of the box. Like the loader import, the injected import is sorted byscalafixAllafterwards. Both anchors are checked in the preflight phase, so a project missing them aborts before any files are written.
After running:
sbt 'compile; scalafmtAll; scalafixAll'compile verifies, scalafmtAll formats the generated files, scalafixAll reorders the auto-wired import in ApplicationLoader.scala (which the script inserts in a fixed position rather than guessing alphabetic order). The generated module compiles green out of the box.
What the scaffold ships:
- Domain —
id: <Aggregate>Id,name: <Aggregate>Name(an opaque type overStringwith a non-empty validation), and audit fieldscreatedAt: Instant,updatedAt: Instant,deletedAt: Option[Instant]on the case class itself (the auction module’s convention). A<Aggregate>.create(id, name, now)smart constructor on the companion sets audit fields. Arename(newName, now): Either[RenameRejection, <Aggregate>]behavior method bumpsupdatedAt— the worked example for the behavior-on-values pattern; replace it with your aggregate’s real transitions. - Repository —
save,find,softDelete, and the auction-styleupdate[E](id, f: <Aggregate> => Either[E, <Aggregate>]): DBInTransaction[Option[Either[E, <Aggregate>]]]that doesSELECT … FOR UPDATEand persists atomically onRight. Thenamefield is a starter — feel free to delete or rename. Add more domain fields by editing the case class, theRow, theTablemapping, the DTO, and the migration in lockstep.
Placeholder substitution
Section titled “Placeholder substitution”The templates use these placeholders (substituted both in filenames and contents):
__Aggregate__→ the PascalCase argument (Wine)__Aggregates__→ capitalized plural, derived from the plural argument (Wines) — used for OpenAPI tags__aggregates__→ the plural argument (wines)__aggregate__→ the lowercase singular, derived (wine)__package__→ the auto-detected project package (madrilenoin the template, your own name afterinit-project.scala)
Substitution order is preserved (longer plural forms before shorter singular forms), but with the current placeholders the order is cosmetic — the trailing __ on each token means __aggregate__ is not a substring of __aggregates__, so neither shadows the other regardless of pass order. The convention is there as a habit if you add overlapping placeholders later.
What it doesn’t do
Section titled “What it doesn’t do”- Doesn’t generate a service spec. The service is a thin wrapper around the repository in the scaffold; add a spec when there’s real service logic.
- Doesn’t enforce ownership / authorization. The generated router takes the
AuthContext(so the route is gated by the framework’s auth gate) but doesn’t yet scope queries byauthContext.userId— that’s where you fill in the domain rules. - Doesn’t run
sbt compile,scalafmtAll, orscalafixAll. All three are bundled into one command in the next-steps printout;scalafixAllis the one that sorts the auto-wired import inApplicationLoader.scala.
dev-console.scala
Section titled “dev-console.scala”Boots a Scala 3 REPL with the project’s wire graph live — the ApplicationLoader is constructed against the real dev Postgres / sttp HTTP client / scheduler / S3 / event bus, and bound to app at the prompt. run(io) executes an IO[A] synchronously, db(action) does the same inside a Skunk session.
./scripts/dev-console.scalaThe first app reference at the prompt boots the application (scala-cli REPL initialises top-level vals lazily). The banner fires then. Subsequent references are free. Example:
scala> db(app.userRepository.find(UserId(UUID.fromString("..."))))madrileno dev console — env=Dev app the ApplicationLoader (transactor, repositories, services) run(io) execute an IO[A] and return A db(action) execute a DB[A] inside a sessionval res0: Option[User] = Some(User(...))How it works
Section titled “How it works”The wrapper depends on a cached classpath at target/console-classpath, written by a hook on the Compile / compile task in build.sbt. Compile is the right trigger because the cached classpath includes target/scala-3.8.2/classes — the project’s own compiled output — so the file is only useful after a successful compile.
- Fresh clone: run
sbt compileonce. Resolves deps, compiles project, writes the cache. - Source change: next
sbt compile(including via~reStart/~test) refreshes the cache. - Deps change in
build.sbt: same — nextsbt compileresolves new deps and rewrites the cache. - Daily dev: nothing extra needed; the file stays valid as long as compile is current.
If the cache is missing, the wrapper prints run \sbt compile` first` and exits non-zero.
Boot time: ./scripts/dev-console.scala returns a REPL prompt in ~5s (scala-cli compile + JVM warmup). The actual ConsoleApplication.boot() (DB pool, HTTP client, scheduler, S3 backend, event bus) only runs on first app reference and adds ~3-5s to that first call.
What it doesn’t do
Section titled “What it doesn’t do”- Doesn’t enforce a read-only mode.
db(...)writes are live against the dev DB. There’s no audit log of REPL commands. A prod-safe console (read-only by default,--prodflag, audit trail) is a separate effort. - Doesn’t auto-refresh the classpath. If you bump a dep and don’t recompile, the cached classpath is stale; the wrapper happily uses it and you’ll get a
ClassNotFoundExceptionat boot for the new dep. Runsbt compileto refresh. - Doesn’t emit OTel traces.
ConsoleApplicationwires noopTracer/Meter, so REPL commands don’t clutter the dev OTel pipeline. - Doesn’t emit app logs. scala-cli’s REPL launcher bundles
slf4j-api 1.7.x, which loads ahead of the project’s2.0.xand finds no matching binder (logback 1.5 ships the 2.x SPI). TheSLF4J: Defaulting to no-operationwarning at startup is the visible side; the practical effect is that logback-routed logs from your code are silently dropped in the REPL. For “what did this service do?” debugging, wrap calls inrun(...)and inspect the return value directly.
doctor.scala
Section titled “doctor.scala”Smoke-tests the dev setup. Run it after a fresh clone, or whenever something feels off (“which thing is broken?”). Diagnose only — does not start anything for you. Exit code 0 on all-green, 1 if any check fails (so CI can use it as a gate later).
./scripts/doctor.scalaOutput on a healthy box:
✓ .env present✓ docker available (Docker version 25.0.2, build 29cf629)✓ docker compose services up madrileno-postgres Up About a minute (healthy) madrileno-mailpit Up About a minute (healthy) madrileno-silo Up About a minute (healthy) madrileno-openobserve Up About a minute (healthy)✓ postgres reachable (localhost:55432)✓ mailpit reachable (http://localhost:58025/api/v1/info → 200)✓ silo reachable (http://localhost:59000/minio/health/live → 200)✓ openobserve reachable (http://localhost:55080/healthz → 200)✓ app responding (http://localhost:9000/v1/health-check → 200)
All 8 checks passed. Happy hacking.On failure, each failed check prints a one-line Hint: pointing at the command that fixes it (cp .env.sample .env, docker compose up -d, sbt "~reStart", etc.). Failures don’t cascade — every independent check runs so you see the full picture in one shot, not iteratively.
The checks:
| # | Step | Notes |
|---|---|---|
| 1 | .env present |
Hints cp .env.sample .env. |
| 2 | docker CLI available |
Skips compose-services check on failure. |
| 3 | docker-compose services running | Reports each of postgres / mailpit / silo / openobserve and its status. |
| 4 | Postgres TCP socket open on PG_PORT |
Reads .env for the port (falls back to .env.sample if .env is missing). |
| 5–7 | Mailpit / Silo / OpenObserve HTTP health endpoints | GET /api/v1/info, /minio/health/live, /healthz respectively. |
| 8 | App responding | GET http://localhost:$PORT/v1/health-check. Hints sbt "~reStart" on failure. |
What it doesn’t do
Section titled “What it doesn’t do”- Doesn’t open a real JDBC connection — just a TCP socket. “Port answers” is enough to distinguish “compose down” from “compose up but something else wrong”; a real connect would need the JDBC driver as a script dep and adds little signal.
- Doesn’t auto-fix. “Doctor” is a smoke test, not a setup script. Copy-paste the hint; running the actual command is your call.
- Doesn’t check OS-level prereqs (sbt installed, JDK version, etc.). Those would have failed earlier (you couldn’t have run this script). If you want a full bootstrap check, that’s a different scope.
- Doesn’t follow
S3_ENDPOINT/OTEL_EXPORTER_OTLP_*_ENDPOINTfrom.env. The Silo / OpenObserve port checks use the host ports fromdocker-compose.yml(59000 / 55080). Doctor’s scope is “is the local dev stack up”, not “is whatever the app is configured to talk to up”. If you’ve pointed the app at remote Silo / OpenObserve, the local doctor checks are still meaningful for the dev stack itself; if you’ve also customised the compose port mappings, doctor needs a matching tweak.
Template-internal CI workflows
Section titled “Template-internal CI workflows”The repo ships four GitHub Actions. .github/workflows/scala.yml is the project’s own CI (format check, scalafix, compile, test) — kept on init like any other project file. The other three are template-internal: they validate template content and are deleted by init-project.scala (along with scripts/check-links.scala, which only the link-check workflow uses):
.github/workflows/link-check.yml— runsscripts/check-links.scalaon PRs touching markdown. The script walks every.md, extracts[text](target)links, verifies internal targets exist (relative paths +#anchorheading slugs). Externals +mailto:/tel:are skipped. Code spans + fenced blocks are stripped first so Scala signatures inside code samples don’t trip the regex..github/workflows/script-tests.yml— four jobs:compile-scripts—scala-cli compileeveryscripts/*.scala, catches dep/import drift in the scripts themselvesrun-doctor— smoke-runsscripts/doctor.scala, accepts exit0or1(CI has no dev stack so doctor’s “checks failed” exit is expected; anything else is a real crash)test-init-project—git clones the repo into a temp dir, runs./scripts/init-project.scala wine-cellar, asserts the rename / auction-drop / workflow-cleanup happened, thensbt compilestest-scaffold-module— same shape, runs./scripts/scaffold-module.scala Wine wines, asserts generated files exist +WineModuleis wired intoApplicationLoader, thensbt compiles
.github/workflows/site-dispatch.yml— on a push tomaintouchingdocs/**orREADME.md, sends adocs-updatedrepository dispatch tomadrileno-dev/madrileno-dev.github.ioso the website rebuilds. Needs theSITE_DISPATCH_TOKENsecret (a fine-grained PAT scoped to the site repo, Contents read/write).
All workflows verify template content (our docs, our scripts). A forked project that wants the same auto-checks can re-add a workflow themselves; doctor.scala and the other scripts stay on init as user-facing tools. check-links.scala goes — without our docs/ tree it has nothing meaningful to walk.
File layout
Section titled “File layout”init-project.scala— standalone, no companion filesscaffold-module.scala+templates/module/— generator + templatesdev-console.scala— wrapper. The REPL predef is embedded as a string inside the wrapper (top of the file), written to a temp file at launch and passed to scala-cli’s REPL. One file, at the cost of no syntax highlighting on the predef section in most editors.doctor.scala— standalone, no companion filescheck-links.scala— standalone; template-only, deleted on init