From c4164a21e1482e82c217e89b445480d858f84b86 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 24 Aug 2026 00:37:26 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(templates):=20add=2029=20templates=20?= =?UTF-8?q?=E2=80=94=2013=20shared=20engines=20and=2016=20app=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grows the built-in catalogue from 22 to 51. Engines (13): rustfs (S3-compatible), etcd, valkey, mailpit, clickhouse, meilisearch, opensearch, neo4j, timescaledb, keycloak, jaeger, mosquitto, consul. Apps (16): go.app, rust.app, python.app -> {fastapi, django, flask}, elixir.phoenix, ruby.rails, java.spring, dotnet.aspnet, deno.app, smee, vue.vite, svelte.kit, nuxt, astro. One template per CONTAINER CONTRACT, not per framework. Go's chi/gin/echo and Rust's axum/actix are libraries: same image, same air/bacon command, so they get one template each rather than five. Python, Elixir and Ruby frameworks genuinely differ in their run command, so those use extends children off a shared parent — the pattern php.nginx -> php.laravel.nginx already established. Every fact here was verified against the upstream image or docs rather than assumed, which caught a number of things that would have shipped broken: * bitnami/etcd is DEAD — subscription-only since Aug 2025, zero public tags. Uses quay.io/coreos/etcd, which publishes no :latest and no floating :v3.6, so the tag must be a full vX.Y.Z. * etcd's image is distroless: /bin and /usr/bin are EMPTY. A CMD-SHELL or curl-based healthcheck fails with "exec: sh: not found" forever, so it uses exec-form etcdctl. * Setting etcd's listen URLs without advertise URLs is a fatal startup error, and listen URLs must be IP literals (shared-etcd there crashes on boot). * ClickHouse with neither CLICKHOUSE_USER nor CLICKHOUSE_PASSWORD set DISABLES network access entirely — the server starts and is simply unreachable. * Keycloak ships neither curl nor wget; its healthcheck uses bash /dev/tcp, and port 9000 stays closed unless KC_HEALTH_ENABLED=true. * Mailpit's MP_DATABASE is the persistence switch, not the volume: a named volume without it silently stores nothing. * Meilisearch's volume must be the /meili_data DIRECTORY, not the .ms file. * timescaledb-ha is NOT built on docker-library/postgres — PGDATA lives under /home/postgres/pgdata, so DECISIONS D8's PG18 move does not apply. * mosquitto 2.1 already ships a working anonymous config, so no conf mount is needed; its persistence is off by default, so it deliberately has NO volume. * jaeger and consul likewise get no volume: both store state in memory in dev mode, and a volume would imply durability neither provides. * cargo-watch is unmaintained (its own README redirects to bacon). bacon needs --headless AND -j run-long: plain -j run buffers output until exit, which for a server is never. * air moved from cosmtrek to air-verse. * redpanda was dropped from the plan on discovering the existing kafka template already IS Redpanda. Binding is the failure that would bite users hardest — a dev server on 127.0.0.1 inside a container looks healthy, publishes its port, and refuses every connection. uvicorn, flask, rails, vite, astro and nuxt all default to loopback and are explicitly overridden here; TestAppTemplatesBindAllInterfaces asserts it. Phoenix is the exception with no env-var fix: its generated config/dev.exs binds loopback and the template says so in a comment, because only the user can change it. Wiring: each engine gets its mandatory exposeEngines row (without one an engine is invisible to `ports`, cannot be exposed and cannot be provisioned) plus a connectionURL case so `devstack ports` prints something usable. Tests: new engine lint table with a per-engine assertion on the detail that would silently break it; app-template tests for bind-all-interfaces and for the dependency dirs a bind mount would shadow (deps/_build, obj/bin, node_modules); and two drift tests — every built-in must have a row in BOTH doc tables, and every template dir must be in the go:embed list. Those drift tests immediately caught pre-existing staleness: templates/README.md had no rows for kafka/nats/rabbitmq/localstack/ministack, and docs/guide/templates.md was missing the mysql/mariadb/mongodb/cassandra/arangodb batch. Both tables are now complete. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/templates.md | 34 +++++++ internal/cli/app_templates_test.go | 118 ++++++++++++++++++++++ internal/cli/template_docs_test.go | 102 +++++++++++++++++++ internal/generate/new_engines_test.go | 102 +++++++++++++++++++ internal/orchestrate/expose.go | 70 +++++++++++++ templates/README.md | 34 +++++++ templates/astro/build/Dockerfile | 6 ++ templates/astro/template.yaml | 28 +++++ templates/clickhouse/template.yaml | 52 ++++++++++ templates/consul/template.yaml | 34 +++++++ templates/deno.app/template.yaml | 44 ++++++++ templates/dotnet.aspnet/build/Dockerfile | 8 ++ templates/dotnet.aspnet/template.yaml | 47 +++++++++ templates/elixir.phoenix/build/Dockerfile | 18 ++++ templates/elixir.phoenix/template.yaml | 36 +++++++ templates/embed.go | 2 +- templates/etcd/template.yaml | 46 +++++++++ templates/go.app/build/Dockerfile | 15 +++ templates/go.app/template.yaml | 51 ++++++++++ templates/jaeger/template.yaml | 30 ++++++ templates/java.spring/build/Dockerfile | 7 ++ templates/java.spring/template.yaml | 46 +++++++++ templates/keycloak/template.yaml | 46 +++++++++ templates/mailpit/template.yaml | 40 ++++++++ templates/meilisearch/template.yaml | 45 +++++++++ templates/mosquitto/template.yaml | 30 ++++++ templates/neo4j/template.yaml | 44 ++++++++ templates/nuxt/build/Dockerfile | 6 ++ templates/nuxt/template.yaml | 34 +++++++ templates/opensearch/template.yaml | 51 ++++++++++ templates/python.app/build/Dockerfile | 20 ++++ templates/python.app/template.yaml | 32 ++++++ templates/python.django/template.yaml | 30 ++++++ templates/python.fastapi/template.yaml | 33 ++++++ templates/python.flask/template.yaml | 38 +++++++ templates/ruby.rails/build/Dockerfile | 19 ++++ templates/ruby.rails/template.yaml | 52 ++++++++++ templates/rust.app/build/Dockerfile | 16 +++ templates/rust.app/template.yaml | 44 ++++++++ templates/rustfs/template.yaml | 46 +++++++++ templates/smee/build/Dockerfile | 11 ++ templates/smee/template.yaml | 38 +++++++ templates/svelte.kit/build/Dockerfile | 6 ++ templates/svelte.kit/template.yaml | 31 ++++++ templates/timescaledb/template.yaml | 48 +++++++++ templates/valkey/template.yaml | 31 ++++++ templates/vue.vite/build/Dockerfile | 6 ++ templates/vue.vite/template.yaml | 32 ++++++ 48 files changed, 1758 insertions(+), 1 deletion(-) create mode 100644 internal/cli/app_templates_test.go create mode 100644 internal/cli/template_docs_test.go create mode 100644 internal/generate/new_engines_test.go create mode 100644 templates/astro/build/Dockerfile create mode 100644 templates/astro/template.yaml create mode 100644 templates/clickhouse/template.yaml create mode 100644 templates/consul/template.yaml create mode 100644 templates/deno.app/template.yaml create mode 100644 templates/dotnet.aspnet/build/Dockerfile create mode 100644 templates/dotnet.aspnet/template.yaml create mode 100644 templates/elixir.phoenix/build/Dockerfile create mode 100644 templates/elixir.phoenix/template.yaml create mode 100644 templates/etcd/template.yaml create mode 100644 templates/go.app/build/Dockerfile create mode 100644 templates/go.app/template.yaml create mode 100644 templates/jaeger/template.yaml create mode 100644 templates/java.spring/build/Dockerfile create mode 100644 templates/java.spring/template.yaml create mode 100644 templates/keycloak/template.yaml create mode 100644 templates/mailpit/template.yaml create mode 100644 templates/meilisearch/template.yaml create mode 100644 templates/mosquitto/template.yaml create mode 100644 templates/neo4j/template.yaml create mode 100644 templates/nuxt/build/Dockerfile create mode 100644 templates/nuxt/template.yaml create mode 100644 templates/opensearch/template.yaml create mode 100644 templates/python.app/build/Dockerfile create mode 100644 templates/python.app/template.yaml create mode 100644 templates/python.django/template.yaml create mode 100644 templates/python.fastapi/template.yaml create mode 100644 templates/python.flask/template.yaml create mode 100644 templates/ruby.rails/build/Dockerfile create mode 100644 templates/ruby.rails/template.yaml create mode 100644 templates/rust.app/build/Dockerfile create mode 100644 templates/rust.app/template.yaml create mode 100644 templates/rustfs/template.yaml create mode 100644 templates/smee/build/Dockerfile create mode 100644 templates/smee/template.yaml create mode 100644 templates/svelte.kit/build/Dockerfile create mode 100644 templates/svelte.kit/template.yaml create mode 100644 templates/timescaledb/template.yaml create mode 100644 templates/valkey/template.yaml create mode 100644 templates/vue.vite/build/Dockerfile create mode 100644 templates/vue.vite/template.yaml diff --git a/docs/guide/templates.md b/docs/guide/templates.md index 6f25c9b..f291719 100644 --- a/docs/guide/templates.md +++ b/docs/guide/templates.md @@ -43,6 +43,24 @@ what you need. A missing **required** param fails fast. | `postgres` | postgres | host, port, user, password, database | 5432 | `version` ("18"), `rootUser` (devstack), `rootPassword` (devstack) | | `redis` | redis | host, port | 6379 | `version` ("7") | | `minio` | minio | host, port, accessKey, secretKey | 9000 | `rootUser` (devstackadmin), `rootPassword` (devstackadmin) | +| `mysql` | mysql | host, port, user, password, database | 3306 | `version` ("8"), `rootUser`, `rootPassword` | +| `mariadb` | mariadb | host, port, user, password, database | 3306 | `version`, `rootUser`, `rootPassword` | +| `mongodb` | mongodb | host, port, user, password, database | 27017 | `version`, `rootUser`, `rootPassword` | +| `cassandra` | cassandra | host, port | 9042 | `version` | +| `arangodb` | arangodb | host, port, user, password | 8529 | `version`, `rootPassword` | +| `valkey` | valkey | host, port | 6379 | `version` ("9"); Redis-compatible fork | +| `timescaledb` | timescaledb | host, port, user, password, database | 5432 | `version` ("pg18"); PGDATA is `/home/postgres/pgdata`, not the postgres image's path | +| `clickhouse` | clickhouse | host, port, user, password, database | 8123 | `version`; user/password are REQUIRED or the server refuses network access | +| `neo4j` | neo4j | host, port, user, password, database | 7687 | `rootPassword` (min 8 chars); Bolt on 7687, browser on 7474 | +| `etcd` | etcd | host, port, endpoint | 2379 | `version` (full `vX.Y.Z` — there is no `:latest`), `advertiseHost` | +| `consul` | consul | host, port, endpoint | 8500 | `version`, `logLevel`; `-dev` keeps state in RAM (BUSL-1.1) | +| `rustfs` | rustfs | host, port, accessKey, secretKey, endpoint | 9000 | `image`, `rootUser`, `rootPassword`; S3-compatible, MinIO-shaped defaults | +| `meilisearch` | meilisearch | host, port, password, endpoint | 7700 | `version`, `masterKey`; DB format is tied to the minor version | +| `opensearch` | opensearch | host, port, endpoint | 9200 | `version`, `heapSize`; needs host `vm.max_map_count` >= 262144 | +| `mailpit` | mailpit | host, port, endpoint | 1025 | `version` (leading `v`), `maxMessages`; SMTP on 1025, inbox UI on 8025 | +| `keycloak` | keycloak | host, port, user, password, endpoint | 8080 | `version` (no bare major tag), `rootUser`, `rootPassword` | +| `jaeger` | jaeger | host, port, endpoint | 4317 | `version`; OTLP gRPC 4317, UI 16686, spans held in memory | +| `mosquitto` | mosquitto | host, port | 1883 | `version`; 2.1 ships a working anonymous config, no volume (no persistence) | | `php.nginx` | — (parent) | — | — | `phpVersion` ("8.3") | | `php.laravel.nginx` | — (extends `php.nginx`) | — | — | `appEnv` (local) | | `node.vite` | — | — | — | `nodeVersion` ("20"); runs `npm run dev` | @@ -52,6 +70,22 @@ what you need. A missing **required** param fails fast. | `react.vite` | — | — | — | `nodeVersion` ("20"); Vite HMR | | `bun.app` | — | — | — | `bunVersion` ("1"); `bun run dev` | | `turborepo` | — | — | — | `nodeVersion` ("20"); `turbo run dev` (monorepo) | +| `go.app` | — | — | — | `goVersion` ("1.27"), `port`; `air` hot reload with polling | +| `rust.app` | — | — | — | `rustVersion` ("1.98"), `port`; `bacon --headless -j run-long` | +| `python.app` | — (parent) | — | — | `pythonVersion` ("3.13"), `port`; uv + a venv outside the bind mount | +| `python.fastapi` | — (extends `python.app`) | — | — | `appModule`; `uvicorn --reload`, polling forced | +| `python.django` | — (extends `python.app`) | — | — | `allowedHosts`; StatReloader already polls | +| `python.flask` | — (extends `python.app`) | — | — | `appModule`, `port` (5000) | +| `elixir.phoenix` | — | — | — | `elixirVersion`, `port`; **edit `config/dev.exs` to bind 0.0.0.0** | +| `ruby.rails` | — | — | — | `rubyVersion`, `port`; clears the stale `server.pid` on start | +| `java.spring` | — | — | — | `image`, `port`; devtools polls, but a `.java` edit needs a recompile | +| `dotnet.aspnet` | — | — | — | `sdkVersion`, `port`; `dotnet watch --no-launch-profile` | +| `deno.app` | — | — | — | `denoVersion`, `entrypoint`, `port`; no polling fallback | +| `vue.vite` | — | — | — | `nodeVersion` ("24"), `port` (5173) | +| `svelte.kit` | — | — | — | `nodeVersion`, `port` (5173); `npm install` runs `svelte-kit sync` | +| `nuxt` | — | — | — | `nodeVersion`, `port` (3000), `hmrPort` (24678 — Nuxt needs TWO ports) | +| `astro` | — | — | — | `nodeVersion`, `port` (4321 — Astro's default, not Vite's) | +| `smee` | — | — | — | `target`, `channel`; forwards smee.io webhooks inward, binds no port | | `kafka` (Redpanda) | kafka | host, port, adminPort | 9092 | `image` | | `nats` | nats | host, port, monitorPort | 4222 | `version` ("2") | | `rabbitmq` | amqp | host, port, mgmtPort | 5672 | `version` ("3"), `user` (devstack) | diff --git a/internal/cli/app_templates_test.go b/internal/cli/app_templates_test.go new file mode 100644 index 0000000..8d40794 --- /dev/null +++ b/internal/cli/app_templates_test.go @@ -0,0 +1,118 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/template" +) + +// TestAppTemplatesRenderAndValidate guards the per-language app templates the way +// TestJSTemplatesRenderAndValidate guards the JS ones: each must resolve, validate +// through compose-go, bind-mount the source, and carry a real reload command. +func TestAppTemplatesRenderAndValidate(t *testing.T) { + src := builtinSource() + for _, name := range []string{ + "go.app", "rust.app", + "python.app", "python.fastapi", "python.django", "python.flask", + "elixir.phoenix", "ruby.rails", "java.spring", "dotnet.aspnet", "deno.app", + "vue.vite", "svelte.kit", "nuxt", "astro", + } { + res, err := template.Resolve(src, name, nil) + if err != nil { + t.Errorf("%s resolve: %v", name, err) + continue + } + compose, err := generate.LintResolved(name, res) + if err != nil { + t.Errorf("%s lint: %v", name, err) + continue + } + doc := string(compose) + if !strings.Contains(doc, "/app") { + t.Errorf("%s: missing the /app source mount that hot reload depends on:\n%s", name, doc) + } + // An app template must never declare provides: — that is what marks a + // template usable as shared infrastructure. + if res.Provides != "" { + t.Errorf("%s declares provides:%q, but app templates are per-project", name, res.Provides) + } + } +} + +// TestAppTemplatesBindAllInterfaces is the single highest-value assertion here. +// +// A dev server bound to 127.0.0.1 inside a container is unreachable from the +// shared network AND from a published host port, and it fails in the most +// confusing way possible: the container is healthy, the port is published, and +// every connection is refused. Each of these frameworks defaults to loopback, so +// the template has to override it explicitly. +func TestAppTemplatesBindAllInterfaces(t *testing.T) { + src := builtinSource() + // name -> a literal proving the template forces an all-interfaces bind. + cases := map[string]string{ + "go.app": "0.0.0.0", // HOST/ADDR env the user's code reads + "python.fastapi": "--host 0.0.0.0", // uvicorn defaults to 127.0.0.1 + "python.django": "0.0.0.0:", // bare runserver binds 127.0.0.1 + "python.flask": "--host 0.0.0.0", // flask run defaults to 127.0.0.1 + "ruby.rails": "-b 0.0.0.0", // rails server binds localhost in dev + "dotnet.aspnet": "http://+:", // ASPNETCORE_URLS, plus --no-launch-profile + "vue.vite": "--host 0.0.0.0", // vite server.host defaults to localhost + "svelte.kit": "--host 0.0.0.0", // + "nuxt": "--host 0.0.0.0", // + "astro": "--host 0.0.0.0", // astro server.host defaults to false + "deno.app": "0.0.0.0", // Deno.serve already defaults to 0.0.0.0 + } + for name, want := range cases { + res, err := template.Resolve(src, name, nil) + if err != nil { + t.Errorf("%s resolve: %v", name, err) + continue + } + compose, err := generate.LintResolved(name, res) + if err != nil { + t.Errorf("%s lint: %v", name, err) + continue + } + if !strings.Contains(string(compose), want) { + t.Errorf("%s: expected %q so the dev server binds all interfaces; without it the "+ + "container looks healthy but refuses every connection:\n%s", name, want, compose) + } + } +} + +// TestAppTemplatesSurviveBindMountShadowing checks the dependency directories that +// a `..:/app` bind mount would otherwise hide. Getting this wrong means the image +// built dependencies that the running container then cannot see. +func TestAppTemplatesSurviveBindMountShadowing(t *testing.T) { + src := builtinSource() + cases := map[string][]string{ + // The bind mount clobbers whatever the build put at these paths, so each + // needs its own volume. + "elixir.phoenix": {"/app/deps", "/app/_build"}, + "dotnet.aspnet": {"/app/obj", "/app/bin"}, + "vue.vite": {"/app/node_modules"}, + "svelte.kit": {"/app/node_modules"}, + "nuxt": {"/app/node_modules"}, + "astro": {"/app/node_modules"}, + } + for name, paths := range cases { + res, err := template.Resolve(src, name, nil) + if err != nil { + t.Errorf("%s resolve: %v", name, err) + continue + } + compose, err := generate.LintResolved(name, res) + if err != nil { + t.Errorf("%s lint: %v", name, err) + continue + } + for _, p := range paths { + if !strings.Contains(string(compose), p) { + t.Errorf("%s: %s is not shielded from the bind mount, so the container "+ + "cannot see what the image build installed there", name, p) + } + } + } +} diff --git a/internal/cli/template_docs_test.go b/internal/cli/template_docs_test.go new file mode 100644 index 0000000..20d21e5 --- /dev/null +++ b/internal/cli/template_docs_test.go @@ -0,0 +1,102 @@ +package cli + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/templates" +) + +// docRowRE pulls the leading backticked name out of a markdown table row. +var docRowRE = regexp.MustCompile("^\\|\\s*`([^`]+)`") + +// repoFile reads a path relative to the repository root. +func repoFile(t *testing.T, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "..", filepath.FromSlash(rel))) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return string(b) +} + +// documentedTemplates returns the template names a markdown file tabulates, +// limited to names that are actually built-in (the same files carry unrelated +// tables for template functions, flags and registry commands). +func documentedTemplates(doc string, builtin map[string]bool) map[string]bool { + out := map[string]bool{} + for _, line := range strings.Split(doc, "\n") { + m := docRowRE.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil { + continue + } + name := strings.TrimSpace(m[1]) + if builtin[name] { + out[name] = true + } + } + return out +} + +// TestBuiltinTemplatesAreDocumented keeps the two hand-maintained template tables +// honest against the embedded set. +// +// Both tables had silently gone stale before this test existed: templates/README.md +// was missing every spec-28 engine (kafka, nats, rabbitmq, localstack, ministack) +// and docs/guide/templates.md was missing the mysql/mariadb/mongodb/cassandra/ +// arangodb batch. Adding a template without a row is now a test failure rather +// than something noticed months later. +func TestBuiltinTemplatesAreDocumented(t *testing.T) { + src := template.NewFSSource(templates.FS) + names := src.List() + if len(names) < 40 { + t.Fatalf("expected the full built-in set, got %d", len(names)) + } + builtin := make(map[string]bool, len(names)) + for _, n := range names { + builtin[n] = true + } + + for _, f := range []string{"templates/README.md", "docs/guide/templates.md"} { + documented := documentedTemplates(repoFile(t, f), builtin) + var missing []string + for _, n := range names { + if !documented[n] { + missing = append(missing, n) + } + } + sort.Strings(missing) + for _, n := range missing { + t.Errorf("%s has no row for the built-in template %q", f, n) + } + } +} + +// TestEveryBuiltinTemplateIsEmbedded catches the other half of the same mistake: +// a template directory that exists on disk but was never added to the go:embed +// allow-list in templates/embed.go is invisible to the binary, so it lints from a +// directory path and then does not exist for any real workspace. +func TestEveryBuiltinTemplateIsEmbedded(t *testing.T) { + entries, err := os.ReadDir(filepath.Join("..", "..", "templates")) + if err != nil { + t.Fatalf("read templates dir: %v", err) + } + embedded := map[string]bool{} + for _, n := range template.NewFSSource(templates.FS).List() { + embedded[n] = true + } + for _, e := range entries { + if !e.IsDir() { + continue + } + if !embedded[e.Name()] { + t.Errorf("templates/%s exists on disk but is not in the go:embed list in "+ + "templates/embed.go, so the binary cannot see it", e.Name()) + } + } +} diff --git a/internal/generate/new_engines_test.go b/internal/generate/new_engines_test.go new file mode 100644 index 0000000..5c90d33 --- /dev/null +++ b/internal/generate/new_engines_test.go @@ -0,0 +1,102 @@ +package generate + +import ( + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/template" + "github.com/open-source-cloud/devstack/templates" +) + +// TestNewEngineTemplatesLint covers the engines added alongside the spec-28 set: +// stores, search, object storage and the developer-facing services. +// +// Each `wantIn` is deliberately the detail that would silently break the engine +// if it were dropped — an image that no longer exists, a healthcheck using a +// binary the image does not ship, or an env var without which the server refuses +// connections. Losing one of these produces a template that lints fine and fails +// on first run, which is exactly what this test exists to prevent. +func TestNewEngineTemplatesLint(t *testing.T) { + src := template.NewFSSource(templates.FS) + cases := []struct { + name string + provides string + port int + wantIn string + why string + }{ + {"valkey", "valkey", 6379, "valkey-cli", + "valkey-cli is the only in-image probe; there is no curl"}, + {"mailpit", "mailpit", 1025, "MP_DATABASE", + "without MP_DATABASE the volume silently persists nothing"}, + {"meilisearch", "meilisearch", 7700, "/meili_data", + "the volume must be the directory, not the .ms file inside it"}, + {"etcd", "etcd", 2379, "ETCD_ADVERTISE_CLIENT_URLS", + "setting listen URLs without advertise URLs is a fatal startup error"}, + {"clickhouse", "clickhouse", 8123, "CLICKHOUSE_PASSWORD", + "with no user/password the entrypoint disables network access entirely"}, + {"neo4j", "neo4j", 7687, "NEO4J_AUTH", + "auth seeding only happens on first boot"}, + {"timescaledb", "timescaledb", 5432, "/home/postgres/pgdata", + "this image is NOT docker-library/postgres; PGDATA lives elsewhere"}, + {"mosquitto", "mosquitto", 1883, "mosquitto_pub", + "the image has no curl; mosquitto_pub speaks the real protocol"}, + {"keycloak", "keycloak", 8080, "KC_HEALTH_ENABLED", + "port 9000 stays closed without it and the healthcheck never passes"}, + {"jaeger", "jaeger", 4317, "13133", + "v2 moved the health port; v1's 14269 does not exist here"}, + {"consul", "consul", 8500, "agent", + "the -dev agent is what enables the UI and default-allow ACLs"}, + {"opensearch", "opensearch", 9200, "discovery.type", + "without single-node discovery the node waits forever for peers"}, + {"rustfs", "rustfs", 9000, "RUSTFS_ACCESS_KEY", + "credentials are how the S3 API is reachable at all"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := template.Resolve(src, tc.name, nil) + if err != nil { + t.Fatalf("resolve %s: %v", tc.name, err) + } + if res.Provides != tc.provides { + t.Errorf("provides = %q, want %q", res.Provides, tc.provides) + } + if res.DefaultPort != tc.port { + t.Errorf("defaultPort = %d, want %d", res.DefaultPort, tc.port) + } + if len(res.Exports) == 0 { + t.Errorf("%s declares no exports, so no project can import from it", tc.name) + } + compose, err := LintResolved(tc.name, res) + if err != nil { + t.Fatalf("lint %s: %v", tc.name, err) + } + if !strings.Contains(string(compose), tc.wantIn) { + t.Errorf("compose is missing %q (%s):\n%s", tc.wantIn, tc.why, compose) + } + }) + } +} + +// TestEngineTemplatesDeclareNoBuild pins the engine/app split: generation rejects +// a shared service with a build context, so an engine template that grows one +// would fail at `up` rather than here. +func TestEngineTemplatesDeclareNoBuild(t *testing.T) { + src := template.NewFSSource(templates.FS) + for _, name := range []string{ + "valkey", "mailpit", "meilisearch", "etcd", "clickhouse", "neo4j", + "timescaledb", "mosquitto", "keycloak", "jaeger", "consul", + "opensearch", "rustfs", + } { + res, err := template.Resolve(src, name, nil) + if err != nil { + t.Fatalf("resolve %s: %v", name, err) + } + if _, ok := res.Service["build"]; ok { + t.Errorf("%s declares build:, but a shared engine must be image-based", name) + } + if _, ok := res.Service["image"]; !ok { + t.Errorf("%s declares no image:", name) + } + } +} diff --git a/internal/orchestrate/expose.go b/internal/orchestrate/expose.go index 13b16fa..0b02f88 100644 --- a/internal/orchestrate/expose.go +++ b/internal/orchestrate/expose.go @@ -62,6 +62,30 @@ var exposeEngines = map[string][]exposePort{ "nats": {{4222, "nats", "nats-expose", 4222, true}, {8222, "monitor", "nats-monitor-expose", 8222, false}}, "kafka": {{19092, "kafka", "kafka-provision", 49092, true}}, "rabbitmq": {{5672, "amqp", "rmq-expose", 5672, true}, {15672, "management", "rmq-mgmt-expose", 15672, false}}, + + // Stores and caches. + "valkey": {{6379, "valkey", "valkey-expose", 6379, true}}, + "timescaledb": {{5432, "postgres", "timescale-expose", 5432, true}}, + "clickhouse": {{8123, "http", "clickhouse-expose", 8123, true}, {9000, "native", "clickhouse-native-expose", 9000, false}}, + "neo4j": {{7687, "bolt", "neo4j-expose", 7687, true}, {7474, "browser", "neo4j-browser-expose", 7474, false}}, + "etcd": {{2379, "etcd", "etcd-expose", 2379, true}}, + "consul": {{8500, "http", "consul-expose", 8500, true}}, + + // Object storage. rustfs speaks the S3 API on the same ports MinIO uses, so + // the two only collide on the host when both are declared — the allocator + // then drifts the second one off its base. + "rustfs": {{9000, "s3", "rustfs-expose", 9000, true}, {9001, "console", "rustfs-console-expose", 9001, false}}, + + // Search. + "meilisearch": {{7700, "http", "meili-expose", 7700, true}}, + "opensearch": {{9200, "http", "opensearch-expose", 9200, true}}, + + // Developer-facing services. These are the ones a HUMAN opens in a browser, + // so their secondary UI ports matter more than usual. + "mailpit": {{1025, "smtp", "mailpit-expose", 1025, true}, {8025, "web", "mailpit-web-expose", 8025, false}}, + "keycloak": {{8080, "http", "keycloak-expose", 8080, true}}, + "jaeger": {{4317, "otlp-grpc", "jaeger-expose", 4317, true}, {16686, "ui", "jaeger-ui-expose", 16686, false}}, + "mosquitto": {{1883, "mqtt", "mosquitto-expose", 1883, true}}, } // ExposableEngine reports whether an engine has a defined host-expose port set. @@ -416,6 +440,52 @@ func connectionURL(engine string, ep exposePort, params map[string]any, port int } user := paramString(params, "user", "devstack") return fmt.Sprintf("amqp://%s@%s", user, host) + + case "valkey": + return "redis://" + host // Valkey speaks the RESP protocol; redis:// clients work unchanged + case "timescaledb": + user := paramString(params, "rootUser", "devstack") + pass := paramString(params, "rootPassword", "devstack") + db := paramString(params, "database", "devstack") + return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", user, pass, host, db) + case "clickhouse": + user := paramString(params, "rootUser", "devstack") + pass := paramString(params, "rootPassword", "devstack") + if ep.label == "native" { + return fmt.Sprintf("clickhouse://%s:%s@%s", user, pass, host) + } + return fmt.Sprintf("http://%s:%s@%s", user, pass, host) + case "neo4j": + if ep.label == "browser" { + return "http://" + host + } + user := paramString(params, "rootUser", "neo4j") + pass := paramString(params, "rootPassword", "devstack1") + return fmt.Sprintf("bolt://%s:%s@%s", user, pass, host) + case "etcd": + return "http://" + host + case "consul": + return "http://" + host // API and the web UI share this port (/ui) + case "rustfs": + return "http://" + host // S3 endpoint / console URL, same shape as minio + case "meilisearch": + return "http://" + host + case "opensearch": + return "http://" + host + case "mailpit": + if ep.label == "web" { + return "http://" + host // the inbox a human opens + } + return "smtp://" + host + case "keycloak": + return "http://" + host + case "jaeger": + if ep.label == "ui" { + return "http://" + host + } + return host // OTLP gRPC endpoint: host:port, no scheme + case "mosquitto": + return "mqtt://" + host } return host } diff --git a/templates/README.md b/templates/README.md index 630450e..c084296 100644 --- a/templates/README.md +++ b/templates/README.md @@ -25,6 +25,40 @@ Built-in service templates, compiled into the binary via `go:embed` | `react.vite` | project | React + Vite dev server (HMR) | | `bun.app` | project | Bun app (`bun run dev`), `oven/bun` base | | `turborepo` | project (monorepo) | `turbo run dev` across packages; pairs with `devstack run` | +| `kafka` | shared engine | `provides: kafka` (Redpanda, single binary, no ZooKeeper); dual advertised listeners | +| `nats` | shared engine | `provides: nats` (JetStream); exposes on `4222`, monitor on `8222` | +| `rabbitmq` | shared engine | `provides: amqp`; AMQP on `5672`, management UI on `15672` | +| `localstack` | shared engine | `provides: aws`; the AWS emulator, on `4566` | +| `ministack` | shared engine | `provides: aws`; a lighter AWS emulator, on `4566` | +| `valkey` | shared engine | `provides: valkey`; the Redis fork. `valkey-cli` healthcheck, no curl in the image | +| `timescaledb` | shared engine | `provides: timescaledb`; PGDATA is `/home/postgres/pgdata` — NOT the postgres image's path | +| `clickhouse` | shared engine | `provides: clickhouse`; HTTP `8123` / native `9000`. User+password are required or the server refuses network access | +| `neo4j` | shared engine | `provides: neo4j`; Bolt `7687`, browser `7474`. Password must be >= 8 chars | +| `etcd` | shared engine | `provides: etcd`; distroless image — exec-form healthcheck only, no shell | +| `consul` | shared engine | `provides: consul`; `-dev` agent, state in RAM. BUSL-1.1 licensed | +| `rustfs` | shared engine | `provides: rustfs`; S3-compatible, same ports and default creds shape as minio | +| `meilisearch` | shared engine | `provides: meilisearch`; on `7700`. DB format is tied to the minor version | +| `opensearch` | shared engine | `provides: opensearch`; on `9200`. Needs host `vm.max_map_count` >= 262144 | +| `mailpit` | shared engine | `provides: mailpit`; SMTP `1025`, inbox UI `8025`. `MP_DATABASE` is the persistence switch | +| `keycloak` | shared engine | `provides: keycloak`; `start-dev` on `8080`. No bare-major image tag exists | +| `jaeger` | shared engine | `provides: jaeger`; OTLP `4317`, UI `16686`. Spans are held in memory | +| `mosquitto` | shared engine | `provides: mosquitto`; MQTT `1883`. 2.1 ships a working anonymous config | +| `go.app` | project | Go + `air`, polling watcher (frameworks are libraries — one template) | +| `rust.app` | project | Rust + `bacon --headless -j run-long` (cargo-watch is unmaintained) | +| `python.app` | project base | Python + uv, venv at `/opt/venv`; parent template | +| `python.fastapi` | project | `extends: python.app`; `uvicorn --reload`, polling forced | +| `python.django` | project | `extends: python.app`; `runserver`, StatReloader already polls | +| `python.flask` | project | `extends: python.app`; `flask run --debug` | +| `elixir.phoenix` | project | `mix phx.server`; needs `inotify-tools`, and `config/dev.exs` must bind 0.0.0.0 | +| `ruby.rails` | project | `bin/rails server -b 0.0.0.0`; clears the stale pidfile on start | +| `java.spring` | project | `mvnw spring-boot:run` + devtools (which polls, but watches `target/classes`) | +| `dotnet.aspnet` | project | `dotnet watch --no-launch-profile`; SDK image already sets polling | +| `deno.app` | project | `deno run --watch`; ENTRYPOINT already execs `deno` | +| `vue.vite` | project | Vue + Vite HMR | +| `svelte.kit` | project | SvelteKit (`vite dev`); `npm install` triggers `svelte-kit sync` | +| `nuxt` | project | Nuxt; needs TWO ports (3000 + 24678 HMR websocket) | +| `astro` | project | Astro; port 4321, not Vite's 5173 | +| `smee` | project | smee.io webhook forwarder; outbound only, binds no port | **Dev-mode hot reload.** The JS/TS app templates bind-mount the project source (`..:/app`, since the generated compose lives in `/.devstack/`) with an diff --git a/templates/astro/build/Dockerfile b/templates/astro/build/Dockerfile new file mode 100644 index 0000000..5146749 --- /dev/null +++ b/templates/astro/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 4321 +CMD ["npm", "run", "dev"] diff --git a/templates/astro/template.yaml b/templates/astro/template.yaml new file mode 100644 index 0000000..860c540 --- /dev/null +++ b/templates/astro/template.yaml @@ -0,0 +1,28 @@ +schemaVersion: 1 +description: "Astro dev server with HMR." +params: + nodeVersion: + type: string + default: "24" + description: "Node major version image tag." + port: + type: int + default: 4321 + description: "Astro dev server port. 4321 is Astro's own default, not Vite's 5173." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # Astro's server.host defaults to false — strictly localhost, unreachable from + # outside the container. The bare `--` is load-bearing: npm swallows --host. + command: ["sh", "-lc", "npm install && npm run dev -- --host 0.0.0.0 --port [[ .params.port ]]"] + environment: + NODE_ENV: development + CHOKIDAR_USEPOLLING: "true" + CHOKIDAR_INTERVAL: "300" + volumes: + - "..:/app" + - "/app/node_modules" diff --git a/templates/clickhouse/template.yaml b/templates/clickhouse/template.yaml new file mode 100644 index 0000000..807b7cd --- /dev/null +++ b/templates/clickhouse/template.yaml @@ -0,0 +1,52 @@ +schemaVersion: 1 +description: "Shared ClickHouse OLAP engine, reached over the shared network at shared-clickhouse:8123 (HTTP) or :9000 (native)." +provides: clickhouse +exports: [host, port, user, password, database] +defaultPort: 8123 +params: + version: + type: string + default: "26.3" + description: "ClickHouse server image tag." + rootUser: + type: string + default: devstack + description: "Application user. Setting this REMOVES the built-in `default` user." + rootPassword: + type: string + default: devstack + description: "Password for the application user." + database: + type: string + default: devstack + description: "Database created on first boot only (ignored once the volume has data)." + +service: + image: "clickhouse/clickhouse-server:[[ .params.version ]]" + restart: unless-stopped + environment: + # Not optional: with neither CLICKHOUSE_USER nor CLICKHOUSE_PASSWORD set, the + # entrypoint DISABLES network access entirely and the server is unreachable. + # Setting them also deletes the built-in `default` user, which is what we want. + CLICKHOUSE_USER: "[[ .params.rootUser ]]" + CLICKHOUSE_PASSWORD: "[[ .params.rootPassword ]]" + CLICKHOUSE_DB: "[[ .params.database ]]" + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" + # Docker's default 1024 file descriptors is far below what MergeTree needs; every + # official example passes this. + ulimits: + nofile: + soft: 262144 + hard: 262144 + volumes: + - "clickhousedata:/var/lib/clickhouse" + healthcheck: + # wget is installed, curl is NOT. This is the image's own probe. + test: ["CMD", "wget", "--spider", "-q", "-T", "2", "http://127.0.0.1:8123/ping"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 20s + +volumes: + clickhousedata: {} diff --git a/templates/consul/template.yaml b/templates/consul/template.yaml new file mode 100644 index 0000000..3048f1a --- /dev/null +++ b/templates/consul/template.yaml @@ -0,0 +1,34 @@ +schemaVersion: 1 +description: "Shared Consul agent in dev mode (service discovery + KV), reached over the shared network at shared-consul:8500." +provides: consul +exports: [host, port, endpoint] +defaultPort: 8500 +params: + version: + type: string + default: "2.0.3" + description: "Consul image tag. Note Consul is BUSL-1.1 licensed (licensor IBM) — fine for local development, but check your policy before adopting it more widely." + logLevel: + type: string + default: info + description: "Agent log level. -dev defaults to DEBUG, which is genuinely noisy." + +service: + image: "hashicorp/consul:[[ .params.version ]]" + restart: unless-stopped + # -dev gives an insecure single-node agent with the UI enabled on the same port + # as the API (:8500/ui) and ACLs in default-allow. -client 0.0.0.0 is what makes + # the API reachable from other containers; gossip and server RPC stay on + # loopback under -dev, so 8300/8301/8302 are not worth publishing. + command: ["agent", "-dev", "-client", "0.0.0.0", "-log-level=[[ .params.logLevel ]]"] + # Deliberately NO data volume: -dev keeps the entire state machine in RAM (raft + # uses an in-memory store), so a volume on /consul/data would persist nothing. + # Durable Consul means dropping -dev, which is a different template. + healthcheck: + # curl genuinely ships in this image. A leader address means the agent has + # elected itself and is serving. + test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8500/v1/status/leader | grep -q ':'"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s diff --git a/templates/deno.app/template.yaml b/templates/deno.app/template.yaml new file mode 100644 index 0000000..58a709a --- /dev/null +++ b/templates/deno.app/template.yaml @@ -0,0 +1,44 @@ +schemaVersion: 1 +description: "Deno dev server with the built-in watcher (deno run --watch)." +params: + denoVersion: + type: string + default: "2.9.5" + description: "Deno image tag." + entrypoint: + type: string + default: "main.ts" + description: "Module to run." + port: + type: int + default: 8000 + description: "Port your server listens on. Deno.serve() already defaults to 0.0.0.0:8000." + +service: + image: "denoland/deno:[[ .params.denoVersion ]]" + restart: unless-stopped + working_dir: /app + environment: + DENO_DIR: /deno-dir/ + HOST: "0.0.0.0" + PORT: "[[ .params.port ]]" + # The image's ENTRYPOINT already execs `deno "$@"`, so the command must NOT + # repeat `deno` — doing so runs `deno deno run …` and fails. + # + # Binding needs no fix: Deno.serve() defaults to 0.0.0.0. + # + # Caveat: Deno's watcher has no polling fallback (no flag, no env var), so on + # WSL2/9p bind mounts where inotify does not propagate, reload will not fire. + command: + - "run" + - "--watch" + - "--allow-net" + - "--allow-env" + - "--allow-read" + - "[[ .params.entrypoint ]]" + volumes: + - "..:/app" + - "denocache:/deno-dir" + +volumes: + denocache: {} diff --git a/templates/dotnet.aspnet/build/Dockerfile b/templates/dotnet.aspnet/build/Dockerfile new file mode 100644 index 0000000..2120f1f --- /dev/null +++ b/templates/dotnet.aspnet/build/Dockerfile @@ -0,0 +1,8 @@ +# syntax=docker/dockerfile:1 +ARG SDK_VERSION=[[ .params.sdkVersion ]] +FROM mcr.microsoft.com/dotnet/sdk:${SDK_VERSION} + +WORKDIR /app +EXPOSE [[ .params.port ]] +# dotnet watch ships in the SDK image — no extra tool to install. +CMD ["dotnet", "watch", "--non-interactive", "run", "--no-launch-profile"] diff --git a/templates/dotnet.aspnet/template.yaml b/templates/dotnet.aspnet/template.yaml new file mode 100644 index 0000000..88f131c --- /dev/null +++ b/templates/dotnet.aspnet/template.yaml @@ -0,0 +1,47 @@ +schemaVersion: 1 +description: "ASP.NET Core dev server (dotnet watch) with hot reload." +params: + sdkVersion: + type: string + default: "10.0" + description: ".NET SDK image tag." + port: + type: int + default: 8080 + description: "HTTP port." + +service: + build: + context: build + dockerfile: Dockerfile + args: + SDK_VERSION: "[[ .params.sdkVersion ]]" + restart: unless-stopped + environment: + ASPNETCORE_ENVIRONMENT: Development + # Binding: the base image already sets ASPNETCORE_HTTP_PORTS=8080, but + # launchSettings.json wins over it — which is why the command below passes + # --no-launch-profile. With that flag, this URL is what actually binds. + ASPNETCORE_URLS: "http://+:[[ .params.port ]]" + # The SDK image already sets this, but making it explicit documents WHY + # reload works over a bind mount: inotify does not cross it. + DOTNET_USE_POLLING_FILE_WATCHER: "true" + DOTNET_WATCH_RESTART_ON_RUDE_EDIT: "true" + DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER: "1" + DOTNET_RUNNING_IN_CONTAINER: "true" + NUGET_PACKAGES: /root/.nuget/packages + # --no-launch-profile is the difference between working and silently binding + # localhost: without it, dotnet run reads Properties/launchSettings.json and its + # applicationUrl overrides ASPNETCORE_URLS. + # --non-interactive stops watch waiting on a TTY that compose does not give it. + command: ["dotnet", "watch", "--non-interactive", "run", "--no-launch-profile"] + volumes: + - "..:/app" + # obj/ and bin/ hold absolute paths from whichever environment wrote them, so + # the container and the host must not share them. + - "/app/obj" + - "/app/bin" + - "nugetcache:/root/.nuget/packages" + +volumes: + nugetcache: {} diff --git a/templates/elixir.phoenix/build/Dockerfile b/templates/elixir.phoenix/build/Dockerfile new file mode 100644 index 0000000..c9ee383 --- /dev/null +++ b/templates/elixir.phoenix/build/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 +ARG ELIXIR_VERSION=[[ .params.elixirVersion ]] +FROM elixir:${ELIXIR_VERSION} + +# inotify-tools is a HARD dependency of phoenix_live_reload (via the file_system +# package) and is not in the base image. Without it, live reload silently never +# fires — the server runs, the browser just never refreshes. +RUN apt-get update \ + && apt-get install -y --no-install-recommends inotify-tools build-essential git \ + && rm -rf /var/lib/apt/lists/* + +# hex and rebar are NOT preinstalled: the official image only builds Elixir from +# source. mix deps.get fails without them. +RUN mix local.hex --force && mix local.rebar --force + +WORKDIR /app +EXPOSE [[ .params.port ]] +CMD ["mix", "phx.server"] diff --git a/templates/elixir.phoenix/template.yaml b/templates/elixir.phoenix/template.yaml new file mode 100644 index 0000000..76ac5f9 --- /dev/null +++ b/templates/elixir.phoenix/template.yaml @@ -0,0 +1,36 @@ +schemaVersion: 1 +description: "Phoenix dev server (mix phx.server) with code and asset reload." +params: + elixirVersion: + type: string + default: "1.20-otp-28" + description: "Elixir image tag (elixir:-otp-)." + port: + type: int + default: 4000 + description: "Phoenix HTTP port." + +service: + build: + context: build + dockerfile: Dockerfile + args: + ELIXIR_VERSION: "[[ .params.elixirVersion ]]" + restart: unless-stopped + environment: + MIX_ENV: dev + PORT: "[[ .params.port ]]" + LANG: C.UTF-8 + ERL_AFLAGS: "-kernel shell_history enabled" + # YOU MUST EDIT config/dev.exs. Phoenix's generated dev config binds LOOPBACK + # ONLY — `http: [ip: {127, 0, 0, 1}, port: 4000]` — and there is no environment + # variable that overrides it. Until you change that to {0, 0, 0, 0}, this + # service is unreachable from other containers and from a published host port, + # no matter what devstack does. + command: ["sh", "-lc", "mix deps.get && exec mix phx.server"] + volumes: + - "..:/app" + # TWO anonymous volumes, not one: the bind mount would otherwise clobber both + # of the trees the image build populated. + - "/app/deps" + - "/app/_build" diff --git a/templates/embed.go b/templates/embed.go index 6dde91a..6e6e32e 100644 --- a/templates/embed.go +++ b/templates/embed.go @@ -7,7 +7,7 @@ package templates import "embed" -//go:embed all:postgres all:mysql all:mariadb all:mongodb all:cassandra all:arangodb all:redis all:minio all:php.nginx all:php.laravel.nginx all:node.vite all:localstack all:ministack all:nats all:kafka all:rabbitmq all:node.express all:node.nestjs all:node.next all:react.vite all:bun.app all:turborepo +//go:embed all:postgres all:mysql all:mariadb all:mongodb all:cassandra all:arangodb all:redis all:minio all:php.nginx all:php.laravel.nginx all:node.vite all:localstack all:ministack all:nats all:kafka all:rabbitmq all:node.express all:node.nestjs all:node.next all:react.vite all:bun.app all:turborepo all:valkey all:mailpit all:meilisearch all:etcd all:clickhouse all:neo4j all:timescaledb all:mosquitto all:keycloak all:jaeger all:consul all:opensearch all:rustfs all:go.app all:rust.app all:python.app all:python.fastapi all:python.django all:python.flask all:elixir.phoenix all:ruby.rails all:java.spring all:dotnet.aspnet all:deno.app all:smee all:vue.vite all:svelte.kit all:nuxt all:astro var builtinFS embed.FS // FS is the embedded built-in templates root: template-name directories at the diff --git a/templates/etcd/template.yaml b/templates/etcd/template.yaml new file mode 100644 index 0000000..6f553f3 --- /dev/null +++ b/templates/etcd/template.yaml @@ -0,0 +1,46 @@ +schemaVersion: 1 +description: "Shared etcd key-value store, reached over the shared network at shared-etcd:2379." +provides: etcd +exports: [host, port, endpoint] +defaultPort: 2379 +params: + version: + type: string + default: "v3.6.14" + description: "etcd image tag. MUST be a full vX.Y.Z — quay.io/coreos/etcd publishes no :latest and no floating :v3.6." + advertiseHost: + type: string + default: shared-etcd + description: "Hostname clients are told to dial. Defaults to the DNS alias of a shared service named `etcd`; override if you name the instance something else." + +service: + # quay.io/coreos/etcd is the etcd project's own registry. NOT bitnami/etcd: + # that repository went subscription-only in Aug 2025 and now has zero public + # tags, so any template referencing it fails to pull. + image: "quay.io/coreos/etcd:[[ .params.version ]]" + restart: unless-stopped + environment: + # Without this the data dir is ${name}.etcd resolved against the image's + # WORKDIR — i.e. /var/lib/etcd/default.etcd — so a volume mounted anywhere + # else silently persists nothing. Same class of trap as the PG18 PGDATA move. + ETCD_DATA_DIR: /etcd-data + # Default is localhost:2379, which answers only the container itself. Must be + # an IP literal: etcd's checkBindURLs rejects a DNS name here outright. + ETCD_LISTEN_CLIENT_URLS: "http://0.0.0.0:2379" + # Setting the listen URL without this is a FATAL startup error, not a warning. + # Advertise URLs may be hostnames, so this one carries the DNS alias. + ETCD_ADVERTISE_CLIENT_URLS: "http://[[ .params.advertiseHost ]]:2379" + volumes: + - "etcddata:/etcd-data" + healthcheck: + # The image is distroless: /bin, /usr/bin and friends are EMPTY, and the only + # executables are etcd, etcdctl and etcdutl. A CMD-SHELL test — or anything + # using curl or wget — fails with "exec: sh: not found" and never goes healthy. + test: ["CMD", "/usr/local/bin/etcdctl", "--endpoints=http://127.0.0.1:2379", "endpoint", "health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + etcddata: {} diff --git a/templates/go.app/build/Dockerfile b/templates/go.app/build/Dockerfile new file mode 100644 index 0000000..0bdf614 --- /dev/null +++ b/templates/go.app/build/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 +ARG GO_VERSION=[[ .params.goVersion ]] +FROM golang:${GO_VERSION}-alpine + +# golang:*-alpine ships only ca-certificates. git is needed whenever a dependency +# resolves via VCS rather than the module proxy. +RUN apk add --no-cache git + +# air-verse is the current module path; github.com/cosmtrek/air is the pre-move +# location and no longer resolves cleanly. +RUN go install github.com/air-verse/air@latest + +WORKDIR /app +EXPOSE [[ .params.port ]] +CMD ["air"] diff --git a/templates/go.app/template.yaml b/templates/go.app/template.yaml new file mode 100644 index 0000000..b9f518e --- /dev/null +++ b/templates/go.app/template.yaml @@ -0,0 +1,51 @@ +schemaVersion: 1 +description: "Go dev server with hot reload (air). Framework-agnostic — chi/gin/echo/fiber are libraries, not different containers." +params: + goVersion: + type: string + default: "1.27" + description: "Go major.minor image tag. air requires go >= 1.26, and every official golang image bakes in GOTOOLCHAIN=local, so an older tag fails to install air." + port: + type: int + default: 8080 + description: "Port your server listens on. Bind 0.0.0.0, not localhost — see below." + +service: + build: + context: build + dockerfile: Dockerfile + args: + GO_VERSION: "[[ .params.goVersion ]]" + restart: unless-stopped + environment: + # air does NOT open the socket — your code does. http.ListenAndServe("localhost:8080", h) + # binds container loopback and is unreachable from the shared network. Read + # these and bind 0.0.0.0. + HOST: "0.0.0.0" + PORT: "[[ .params.port ]]" + ADDR: "0.0.0.0:[[ .params.port ]]" + # The bind-mounted source is not a git checkout from the container's view. + GOFLAGS: "-buildvcs=false" + CGO_ENABLED: "0" + # Caches live outside the bind mount, which is why — unlike node_modules — + # no anonymous volume is needed here. + GOMODCACHE: /gomodcache + GOCACHE: /gocache + # --build.poll is what makes reload work on WSL2/9p and Docker Desktop, where + # inotify does not cross the bind mount. air defaults to fsnotify (poll = false). + command: + - "air" + - "--build.cmd=go build -o /tmp/main ./..." + - "--build.bin=/tmp/main" + - "--build.poll=true" + - "--build.poll_interval=500" + - "--build.send_interrupt=true" + volumes: + # compose lives in /.devstack, so ".." is the project source. + - "..:/app" + - "gomodcache:/gomodcache" + - "gocache:/gocache" + +volumes: + gomodcache: {} + gocache: {} diff --git a/templates/jaeger/template.yaml b/templates/jaeger/template.yaml new file mode 100644 index 0000000..776179e --- /dev/null +++ b/templates/jaeger/template.yaml @@ -0,0 +1,30 @@ +schemaVersion: 1 +description: "Shared Jaeger tracing backend (all-in-one), OTLP at shared-jaeger:4317; UI on 16686." +provides: jaeger +exports: [host, port, endpoint] +defaultPort: 4317 +params: + version: + type: string + default: "2.20.0" + description: "Jaeger v2 image tag. Only X.Y.Z and `latest` exist — `2` and `2.20` both 404." + +service: + image: "jaegertracing/jaeger:[[ .params.version ]]" + restart: unless-stopped + # No command: the binary carries an embedded all-in-one config. + # + # Deliberately NO data volume. The embedded default config stores spans in + # MEMORY, so a volume would persist nothing — traces are lost on restart by + # design. Persisting needs a mounted badger config and an explicit + # `command: ["--config", "/etc/jaeger/config.yml"]`, which is out of scope for a + # dev tracing backend. + healthcheck: + # v2 serves health on 13133. v1's admin port 14269 does not exist here, so a + # healthcheck copied from a v1 example fails permanently. wget ships (alpine + # base); curl does not. + test: ["CMD-SHELL", "wget -qO- http://localhost:13133/status || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s diff --git a/templates/java.spring/build/Dockerfile b/templates/java.spring/build/Dockerfile new file mode 100644 index 0000000..4ee4e49 --- /dev/null +++ b/templates/java.spring/build/Dockerfile @@ -0,0 +1,7 @@ +# syntax=docker/dockerfile:1 +ARG BASE_IMAGE=[[ .params.image ]] +FROM ${BASE_IMAGE} + +WORKDIR /app +EXPOSE [[ .params.port ]] +CMD ["./mvnw", "spring-boot:run"] diff --git a/templates/java.spring/template.yaml b/templates/java.spring/template.yaml new file mode 100644 index 0000000..7e20577 --- /dev/null +++ b/templates/java.spring/template.yaml @@ -0,0 +1,46 @@ +schemaVersion: 1 +description: "Spring Boot dev server (mvnw spring-boot:run) with devtools restart." +params: + image: + type: string + default: "maven:3.9-eclipse-temurin-21" + description: "Maven + JDK base image. Swap the temurin version for a different JDK." + port: + type: int + default: 8080 + description: "Spring Boot HTTP port." + +service: + build: + context: build + dockerfile: Dockerfile + args: + BASE_IMAGE: "[[ .params.image ]]" + restart: unless-stopped + environment: + SPRING_PROFILES_ACTIVE: dev + SERVER_PORT: "[[ .params.port ]]" + # Binding needs no fix here — unlike Rails, Phoenix and uvicorn, Spring Boot + # already listens on all interfaces unless server.address is set. + # + # devtools polls rather than using inotify, so this works over WSL2/9p with + # no polling switch to enable. + SPRING_DEVTOOLS_RESTART_ENABLED: "true" + SPRING_DEVTOOLS_RESTART_POLL_INTERVAL: "2s" + SPRING_DEVTOOLS_RESTART_QUIET_PERIOD: "1s" + MAVEN_OPTS: "-Dmaven.repo.local=/root/.m2/repository" + JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75.0" + # IMPORTANT — devtools watches target/classes, NOT src/. `spring-boot:run` on + # its own therefore never reacts to a .java edit: something has to recompile. + # Either let your IDE build into target/classes, run + # `devstack shell -- ./mvnw compile` after a change, or set + # spring.devtools.restart.trigger-file in application-dev.properties and touch + # that file. This is a Spring Boot behaviour, not a devstack limitation. + command: ["sh", "-lc", "exec ./mvnw -q spring-boot:run"] + volumes: + - "..:/app" + # The Maven repository is large and slow to refill; keep it off the bind mount. + - "m2repo:/root/.m2" + +volumes: + m2repo: {} diff --git a/templates/keycloak/template.yaml b/templates/keycloak/template.yaml new file mode 100644 index 0000000..a703ec1 --- /dev/null +++ b/templates/keycloak/template.yaml @@ -0,0 +1,46 @@ +schemaVersion: 1 +description: "Shared Keycloak identity provider (dev mode), reached over the shared network at shared-keycloak:8080." +provides: keycloak +exports: [host, port, user, password, endpoint] +defaultPort: 8080 +params: + version: + type: string + default: "26.7.2" + description: "Keycloak image tag. quay.io carries major.minor floats (26.7) and full patch tags — a bare major like `26` does NOT exist." + rootUser: + type: string + default: admin + description: "Bootstrap admin, seeded on FIRST start only." + rootPassword: + type: string + default: admin + description: "Bootstrap admin password (local development)." + +service: + image: "quay.io/keycloak/keycloak:[[ .params.version ]]" + restart: unless-stopped + # start-dev uses the file-backed H2 database under /opt/keycloak/data and always + # re-runs the Quarkus build on boot, so first start is slow. It is explicitly + # unsupported for production — this template is a dev identity provider. + command: ["start-dev"] + environment: + # Seeds the master realm on first start only; ignored against an existing volume. + KC_BOOTSTRAP_ADMIN_USERNAME: "[[ .params.rootUser ]]" + KC_BOOTSTRAP_ADMIN_PASSWORD: "[[ .params.rootPassword ]]" + # Port 9000 stays CLOSED without this, and the healthcheck below would never + # pass — which would abort the up saga rather than just look untidy. + KC_HEALTH_ENABLED: "true" + volumes: + - "keycloakdata:/opt/keycloak/data" + healthcheck: + # The image ships neither curl nor wget, so this uses bash's /dev/tcp — the + # same approach Keycloak's own documentation gives for container health. + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 40s + +volumes: + keycloakdata: {} diff --git a/templates/mailpit/template.yaml b/templates/mailpit/template.yaml new file mode 100644 index 0000000..ec7b670 --- /dev/null +++ b/templates/mailpit/template.yaml @@ -0,0 +1,40 @@ +schemaVersion: 1 +description: "Shared Mailpit SMTP catcher, reached over the shared network at shared-mailpit:1025 (web UI on 8025)." +provides: mailpit +exports: [host, port, endpoint] +defaultPort: 1025 +params: + version: + type: string + default: "v1.31" + description: "Mailpit image tag. Tags carry a leading v — 1.31 does not exist." + maxMessages: + type: int + default: 5000 + description: "Ring-buffer size; older mail is evicted past this." + +service: + image: "axllent/mailpit:[[ .params.version ]]" + restart: unless-stopped + environment: + # MP_DATABASE is the persistence switch, NOT the volume: the default store is + # a temp file deleted on exit, so a volume alone silently keeps nothing. + MP_DATABASE: /data/mailpit.db + MP_MAX_MESSAGES: "[[ .params.maxMessages ]]" + # Accept whatever credentials an app sends — dev apps rarely disable SMTP auth. + MP_SMTP_AUTH_ACCEPT_ANY: "1" + MP_SMTP_AUTH_ALLOW_INSECURE: "1" + volumes: + - "mailpitdata:/data" + healthcheck: + # Mailpit's own binary; this is the check upstream's Dockerfile declares. The + # CMD form bypasses ENTRYPOINT, so the absolute path is required. No curl in + # the image (alpine + tzdata only). + test: ["CMD", "/mailpit", "readyz"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + mailpitdata: {} diff --git a/templates/meilisearch/template.yaml b/templates/meilisearch/template.yaml new file mode 100644 index 0000000..9b0e358 --- /dev/null +++ b/templates/meilisearch/template.yaml @@ -0,0 +1,45 @@ +schemaVersion: 1 +description: "Shared Meilisearch engine, reached over the shared network at shared-meilisearch:7700." +provides: meilisearch +exports: [host, port, password, endpoint] +defaultPort: 7700 +params: + version: + type: string + default: "v1.53" + description: "Meilisearch image tag (leading v). The on-disk DB format is tied to the minor version — see below." + masterKey: + type: string + default: devstackdevstackdevstack + description: "MEILI_MASTER_KEY. Must be at least 16 bytes; exported to consumers as `password`." + env: + type: string + default: development + description: "MEILI_ENV: development serves the search preview UI; production requires a strong key." + +service: + image: "getmeili/meilisearch:[[ .params.version ]]" + restart: unless-stopped + environment: + MEILI_ENV: "[[ .params.env ]]" + MEILI_MASTER_KEY: "[[ .params.masterKey ]]" + MEILI_NO_ANALYTICS: "true" + volumes: + # Mount the DIRECTORY, not the file: WORKDIR is /meili_data and db_path + # defaults to the relative ./data.ms, so the database lands inside it. + # Dumps and snapshots default under here too, so one volume covers everything. + # + # Bumping the image tag against an existing volume aborts at startup with an + # incompatible-database error; migrating needs a dump + import. + - "meilidata:/meili_data" + healthcheck: + # curl ships in the runtime stage specifically for health checks, and + # GET /health is unauthenticated so this works with a master key set. + test: ["CMD", "curl", "-fsS", "http://localhost:7700/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + meilidata: {} diff --git a/templates/mosquitto/template.yaml b/templates/mosquitto/template.yaml new file mode 100644 index 0000000..43313e3 --- /dev/null +++ b/templates/mosquitto/template.yaml @@ -0,0 +1,30 @@ +schemaVersion: 1 +description: "Shared Eclipse Mosquitto MQTT broker, reached over the shared network at shared-mosquitto:1883." +provides: mosquitto +exports: [host, port] +defaultPort: 1883 +params: + version: + type: string + default: "2.1.2-alpine" + description: "Mosquitto image tag. Stay on the 2.1 line: 2.0 images ship a config with no listener and no allow_anonymous, so they need a mounted conf to work at all." + +service: + image: "eclipse-mosquitto:[[ .params.version ]]" + restart: unless-stopped + # No config mount and no environment: the 2.1 image already ships a working + # anonymous config (listener 1883 + allow_anonymous true) at + # /mosquitto/config/mosquitto.conf. This image reads no environment variables — + # there is deliberately no MOSQUITTO_USERNAME/PASSWORD here, because none exist. + # + # Deliberately NO data volume: the shipped config enables no persistence, so a + # volume at /mosquitto/data would store nothing and imply durability the broker + # does not provide. A dev broker is ephemeral; retained messages and queued QoS + # 1/2 traffic do not survive a restart. + healthcheck: + # mosquitto_pub ships in the image and speaks the real protocol. There is no + # curl here, and 2.1's HTTP dashboard port is not enabled by default. + test: ["CMD", "mosquitto_pub", "-h", "127.0.0.1", "-p", "1883", "-t", "healthcheck", "-m", "ok", "-q", "1"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/neo4j/template.yaml b/templates/neo4j/template.yaml new file mode 100644 index 0000000..0ee85e2 --- /dev/null +++ b/templates/neo4j/template.yaml @@ -0,0 +1,44 @@ +schemaVersion: 1 +description: "Shared Neo4j graph database, reached over the shared network at shared-neo4j:7687 (Bolt); browser on 7474." +provides: neo4j +exports: [host, port, user, password, database] +defaultPort: 7687 +params: + version: + type: string + default: "2026.07.1" + description: "Neo4j image tag (calendar-versioned since 2025)." + rootUser: + type: string + default: neo4j + description: "Neo4j only supports the built-in `neo4j` administrator on first boot." + rootPassword: + type: string + default: devstack1 + description: "Must be at least 8 characters — a shorter one makes the container exit at startup." + +service: + image: "neo4j:[[ .params.version ]]" + restart: unless-stopped + environment: + # Applied ONLY when the auth store under /data is empty. On a later start with + # a populated volume this is silently ignored and the stored password wins. + NEO4J_AUTH: "[[ .params.rootUser ]]/[[ .params.rootPassword ]]" + volumes: + # The image declares VOLUME /data /logs. /logs is mounted too so Docker does + # not create a fresh anonymous volume on every run. + - "neo4jdata:/data" + - "neo4jlogs:/logs" + healthcheck: + # curl is explicitly purged from this image; wget survives. 7474 is the HTTP + # port — the Bolt port does not speak HTTP, so probe 7474 even though drivers + # connect on 7687. + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider localhost:7474 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + neo4jdata: {} + neo4jlogs: {} diff --git a/templates/nuxt/build/Dockerfile b/templates/nuxt/build/Dockerfile new file mode 100644 index 0000000..cc52328 --- /dev/null +++ b/templates/nuxt/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/templates/nuxt/template.yaml b/templates/nuxt/template.yaml new file mode 100644 index 0000000..0059e45 --- /dev/null +++ b/templates/nuxt/template.yaml @@ -0,0 +1,34 @@ +schemaVersion: 1 +description: "Nuxt dev server with HMR (needs TWO ports — see below)." +params: + nodeVersion: + type: string + default: "24" + description: "Node major version image tag." + port: + type: int + default: 3000 + description: "Nuxt HTTP port." + hmrPort: + type: int + default: 24678 + description: "Vite HMR websocket port. Nuxt runs Vite in middleware mode behind its own Nitro server, so HMR cannot share the HTTP port the way plain Vite does." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # The bare `--` is load-bearing. Also note `-h` is Nuxt's SHORT FLAG FOR --host, + # not --help — `nuxt dev -h` binds a host rather than printing help. + command: ["sh", "-lc", "npm install && npm run dev -- --host 0.0.0.0 --port [[ .params.port ]]"] + environment: + NODE_ENV: development + NUXT_HOST: "0.0.0.0" + NUXT_PORT: "[[ .params.port ]]" + CHOKIDAR_USEPOLLING: "true" + CHOKIDAR_INTERVAL: "300" + volumes: + - "..:/app" + - "/app/node_modules" diff --git a/templates/opensearch/template.yaml b/templates/opensearch/template.yaml new file mode 100644 index 0000000..1b6e946 --- /dev/null +++ b/templates/opensearch/template.yaml @@ -0,0 +1,51 @@ +schemaVersion: 1 +description: "Shared OpenSearch engine (single node, security disabled), reached over the shared network at shared-opensearch:9200." +provides: opensearch +exports: [host, port, endpoint] +defaultPort: 9200 +params: + version: + type: string + default: "3.8.0" + description: "OpenSearch image tag." + heapSize: + type: string + default: "512m" + description: "JVM heap (Xms and Xmx are set to the same value). The container still wants ~1.5-2GB RSS on top." + clusterName: + type: string + default: devstack + description: "cluster.name." + +service: + image: "opensearchproject/opensearch:[[ .params.version ]]" + restart: unless-stopped + environment: + discovery.type: single-node + cluster.name: "[[ .params.clusterName ]]" + node.name: opensearch + # Security off: this is a local dev search engine on a tool-owned network. + # With it off there is no user or password at all, which is why exports carry + # only host/port/endpoint. + DISABLE_SECURITY_PLUGIN: "true" + DISABLE_INSTALL_DEMO_CONFIG: "true" + DISABLE_PERFORMANCE_ANALYZER_AGENT_CLI: "true" + OPENSEARCH_JAVA_OPTS: "-Xms[[ .params.heapSize ]] -Xmx[[ .params.heapSize ]]" + # Deliberately NOT setting bootstrap.memory_lock: it is only safe paired with + # a memlock ulimit of -1, and without that the node refuses to start. + ulimits: + nofile: + soft: 65536 + hard: 65536 + volumes: + - "opensearchdata:/usr/share/opensearch/data" + healthcheck: + # curl ships in the Amazon Linux 2023 base. + test: ["CMD-SHELL", "curl -fsS http://localhost:9200/_cluster/health || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 40s + +volumes: + opensearchdata: {} diff --git a/templates/python.app/build/Dockerfile b/templates/python.app/build/Dockerfile new file mode 100644 index 0000000..4235bb5 --- /dev/null +++ b/templates/python.app/build/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 +ARG PYTHON_VERSION=[[ .params.pythonVersion ]] +FROM python:${PYTHON_VERSION}-slim + +# uv resolves and installs far faster than pip, which matters because these +# templates re-sync dependencies on every container start. +RUN pip install --no-cache-dir uv + +# A venv outside /app, so the bind mount cannot shadow it. +ENV UV_PROJECT_ENVIRONMENT=/opt/venv \ + VIRTUAL_ENV=/opt/venv \ + PATH=/opt/venv/bin:$PATH +RUN uv venv /opt/venv + +# NOTE: -slim carries no gcc and no -dev headers. A dependency without a +# manylinux/aarch64 wheel (mysqlclient, python-ldap, old psycopg2) will fail to +# install — prefer psycopg[binary] / *-binary wheels, or add build-essential here. + +WORKDIR /app +EXPOSE [[ .params.port ]] diff --git a/templates/python.app/template.yaml b/templates/python.app/template.yaml new file mode 100644 index 0000000..1c7f48e --- /dev/null +++ b/templates/python.app/template.yaml @@ -0,0 +1,32 @@ +schemaVersion: 1 +description: "Python toolchain base built from ./build (parent template for Python stacks: uv, a venv outside the bind mount, and dependency sync at start)." +params: + pythonVersion: + type: string + default: "3.13" + description: "Python version base image tag." + port: + type: int + default: 8000 + description: "Port the dev server listens on." + +service: + build: + context: build + dockerfile: Dockerfile + args: + PYTHON_VERSION: "[[ .params.pythonVersion ]]" + restart: unless-stopped + environment: + PYTHONUNBUFFERED: "1" + # Stops the container writing root-owned .pyc files into the user's repo. + PYTHONDONTWRITEBYTECODE: "1" + # The venv lives OUTSIDE /app deliberately. If it sat at /app/.venv the bind + # mount would shadow it, and a polling file watcher would then stat tens of + # thousands of site-packages files on every cycle. + UV_PROJECT_ENVIRONMENT: /opt/venv + VIRTUAL_ENV: /opt/venv + UV_LINK_MODE: copy + volumes: + # compose lives in /.devstack, so ".." is the project source. + - "..:/app" diff --git a/templates/python.django/template.yaml b/templates/python.django/template.yaml new file mode 100644 index 0000000..07da17a --- /dev/null +++ b/templates/python.django/template.yaml @@ -0,0 +1,30 @@ +schemaVersion: 1 +extends: python.app +description: "Django dev server (manage.py runserver) with the built-in autoreloader (extends python.app)." +params: + allowedHosts: + type: string + default: "*" + description: "DJANGO_ALLOWED_HOSTS. Wide open is correct for local dev — see the note below." + +service: + environment: + DJANGO_DEBUG: "1" + # The #1 Django-in-a-container failure: with DEBUG=True and an empty + # ALLOWED_HOSTS, Django only accepts Host headers matching .localhost, + # 127.0.0.1 and [::1] — so a request to shared-network DNS or a published + # host port returns 400 Bad Request. Your settings.py must actually read this. + DJANGO_ALLOWED_HOSTS: "[[ .params.allowedHosts ]]" + # No polling env var is needed and none should be added: Django's StatReloader + # already polls once a second, so it is the one framework here that hot-reloads + # correctly over 9p/WSL2 out of the box. Do NOT add pywatchman. + # + # 0.0.0.0:PORT is mandatory — a bare `runserver` binds 127.0.0.1. + command: + - "sh" + - "-lc" + - >- + if [ -f uv.lock ]; then uv sync --frozen; + elif [ -f pyproject.toml ]; then uv sync; + elif [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi; + exec python manage.py runserver 0.0.0.0:[[ .params.port ]] diff --git a/templates/python.fastapi/template.yaml b/templates/python.fastapi/template.yaml new file mode 100644 index 0000000..ff9a198 --- /dev/null +++ b/templates/python.fastapi/template.yaml @@ -0,0 +1,33 @@ +schemaVersion: 1 +extends: python.app +description: "FastAPI dev server on uvicorn with --reload (extends python.app)." +params: + appModule: + type: string + default: "app.main:app" + description: "The ASGI application import path passed to uvicorn." + +service: + environment: + APP_MODULE: "[[ .params.appModule ]]" + # uvicorn's reloader is watchfiles, which uses inotify — that does not cross a + # Docker Desktop/WSL2 bind mount. Forcing polling is what makes reload work. + WATCHFILES_FORCE_POLLING: "true" + WATCHFILES_POLL_DELAY_MS: "500" + # Sync deps at start so a teammate adding one does not need an image rebuild, + # then exec so uvicorn is PID 1 and `compose stop` reaches it directly instead + # of timing out after 10s. + # + # --host 0.0.0.0 is mandatory: uvicorn defaults to 127.0.0.1 and would be + # unreachable. (The same trap bites `fastapi dev`, which also binds 127.0.0.1 — + # only `fastapi run` binds 0.0.0.0.) + # + # --reload-dir /app keeps the watcher off site-packages. + command: + - "sh" + - "-lc" + - >- + if [ -f uv.lock ]; then uv sync --frozen; + elif [ -f pyproject.toml ]; then uv sync; + elif [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi; + exec uvicorn "${APP_MODULE}" --host 0.0.0.0 --port [[ .params.port ]] --reload --reload-dir /app diff --git a/templates/python.flask/template.yaml b/templates/python.flask/template.yaml new file mode 100644 index 0000000..1d61239 --- /dev/null +++ b/templates/python.flask/template.yaml @@ -0,0 +1,38 @@ +schemaVersion: 1 +extends: python.app +description: "Flask dev server (flask run --debug) with the Werkzeug reloader (extends python.app)." +params: + port: + type: int + default: 5000 + description: "Flask's default port. Note 5000 is taken by AirPlay Receiver on macOS, so the host-side allocation may land elsewhere." + appModule: + type: string + default: "app" + description: "FLASK_APP — the module or factory Flask should import." + +service: + environment: + FLASK_APP: "[[ .params.appModule ]]" + FLASK_DEBUG: "1" + FLASK_RUN_HOST: "0.0.0.0" + FLASK_RUN_PORT: "[[ .params.port ]]" + # The debugger PIN prompt is noise on a local-only dev container. + WERKZEUG_DEBUG_PIN: "off" + # Flask is the one framework here with NO polling switch: Werkzeug's reloader + # picks watchdog (inotify) whenever watchdog is importable and offers no env var + # to force the stat loop. If reload stops working over a WSL2/9p bind mount, + # remove watchdog from the project's dependencies — the stat fallback polls and + # works. + # + # --debug is what enables the reloader, and it also enables the interactive + # debugger, which executes code from the browser. Fine on the tool-owned network; + # never expose this service publicly. + command: + - "sh" + - "-lc" + - >- + if [ -f uv.lock ]; then uv sync --frozen; + elif [ -f pyproject.toml ]; then uv sync; + elif [ -f requirements.txt ]; then uv pip install -r requirements.txt; fi; + exec flask run --debug --host 0.0.0.0 --port [[ .params.port ]] diff --git a/templates/ruby.rails/build/Dockerfile b/templates/ruby.rails/build/Dockerfile new file mode 100644 index 0000000..5b6f790 --- /dev/null +++ b/templates/ruby.rails/build/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 +ARG RUBY_VERSION=[[ .params.rubyVersion ]] +FROM ruby:${RUBY_VERSION} + +# slim carries no compiler and no client libraries. build-essential covers native +# gem extensions; libpq/libyaml/libvips are the ones a default Rails app reaches +# for (pg, psych, ActiveStorage variants). +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential git pkg-config libpq-dev libyaml-dev libvips \ + && rm -rf /var/lib/apt/lists/* + +ENV GEM_HOME=/usr/local/bundle \ + BUNDLE_APP_CONFIG=/usr/local/bundle \ + PATH=/usr/local/bundle/bin:$PATH + +WORKDIR /app +EXPOSE [[ .params.port ]] +CMD ["bin/rails", "server", "-b", "0.0.0.0"] diff --git a/templates/ruby.rails/template.yaml b/templates/ruby.rails/template.yaml new file mode 100644 index 0000000..d67793f --- /dev/null +++ b/templates/ruby.rails/template.yaml @@ -0,0 +1,52 @@ +schemaVersion: 1 +description: "Ruby on Rails dev server (bin/rails server) with Rails' built-in code reloader." +params: + rubyVersion: + type: string + default: "3.4-slim-trixie" + description: "Ruby image tag." + port: + type: int + default: 3000 + description: "Rails HTTP port." + +service: + build: + context: build + dockerfile: Dockerfile + args: + RUBY_VERSION: "[[ .params.rubyVersion ]]" + restart: unless-stopped + environment: + RAILS_ENV: development + # rails server binds `localhost` in development, so the port would be + # published but every connection refused. -b 0.0.0.0 below is the real fix; + # BINDING is the env form Rails also honours. + BINDING: "0.0.0.0" + PORT: "[[ .params.port ]]" + # Rails 7+ host authorization returns "Blocked host" for anything not listed. + # Add the service's DNS name so in-network callers work too. + RAILS_DEVELOPMENT_HOSTS: ".localhost,.local,app" + GEM_HOME: /usr/local/bundle + BUNDLE_APP_CONFIG: /usr/local/bundle + BOOTSNAP_CACHE_DIR: /tmp/bootsnap + # No polling flag needed: Rails' default ActiveSupport::FileUpdateChecker stats + # files per request, so it already works over 9p/WSL2. + # + # The rm is not cosmetic: rails writes tmp/pids/server.pid inside the bind + # mount, so a container killed with SIGKILL leaves a stale pidfile on the HOST + # and the next start dies with "A server is already running". + command: + - "sh" + - "-lc" + - >- + rm -f tmp/pids/server.pid; + bundle check || bundle install; + exec bin/rails server -b 0.0.0.0 -p [[ .params.port ]] + volumes: + - "..:/app" + # Gems live outside the bind mount so it cannot shadow them. + - "bundlegems:/usr/local/bundle" + +volumes: + bundlegems: {} diff --git a/templates/rust.app/build/Dockerfile b/templates/rust.app/build/Dockerfile new file mode 100644 index 0000000..0c3f43f --- /dev/null +++ b/templates/rust.app/build/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 +ARG RUST_VERSION=[[ .params.rustVersion ]] +FROM rust:${RUST_VERSION}-trixie + +# bacon replaces cargo-watch, which is no longer maintained (its own README points +# users at bacon). bacon publishes no prebuilt binaries, so this compiles from +# source and makes the first image build slow — it is cached thereafter. +RUN cargo install --locked bacon + +# The official image installs rustup with --profile minimal: no clippy, no +# rustfmt. Add clippy so `bacon -j clippy` works without a rebuild. +RUN rustup component add clippy rustfmt + +WORKDIR /app +EXPOSE [[ .params.port ]] +CMD ["bacon", "--headless", "-j", "run-long"] diff --git a/templates/rust.app/template.yaml b/templates/rust.app/template.yaml new file mode 100644 index 0000000..c2f7b05 --- /dev/null +++ b/templates/rust.app/template.yaml @@ -0,0 +1,44 @@ +schemaVersion: 1 +description: "Rust dev server with rebuild-on-change (bacon). Framework-agnostic — axum/actix/rocket are libraries, not different containers." +params: + rustVersion: + type: string + default: "1.98" + description: "Rust image tag. Uses the FULL image, not -slim: slim strips the tooling bacon needs to build." + port: + type: int + default: 3000 + description: "Port your server listens on. Bind 0.0.0.0, not 127.0.0.1." + +service: + build: + context: build + dockerfile: Dockerfile + args: + RUST_VERSION: "[[ .params.rustVersion ]]" + restart: unless-stopped + environment: + HOST: "0.0.0.0" + PORT: "[[ .params.port ]]" + ROCKET_ADDRESS: "0.0.0.0" + RUST_BACKTRACE: "1" + # Build artifacts go to a named volume, NOT into the user's bind-mounted + # target/ — keeps container artifacts out of the host tree and makes rebuilds + # survive a recreate. + CARGO_TARGET_DIR: /target + CARGO_INCREMENTAL: "1" + # --headless because bacon is a TUI and would render screen redraws into logs. + # -j run-long (NOT -j run): `run` is background=true and buffers output until the + # process exits, which for a server is never. `run-long` is bacon's shipped job + # for long-running processes. + command: ["bacon", "--headless", "-j", "run-long"] + volumes: + - "..:/app" + # NAMED, not anonymous: an anonymous volume is dropped by `compose down -v`, + # and losing target/ costs a full cold rebuild. + - "cargotarget:/target" + - "cargoregistry:/usr/local/cargo/registry" + +volumes: + cargotarget: {} + cargoregistry: {} diff --git a/templates/rustfs/template.yaml b/templates/rustfs/template.yaml new file mode 100644 index 0000000..931de74 --- /dev/null +++ b/templates/rustfs/template.yaml @@ -0,0 +1,46 @@ +schemaVersion: 1 +description: "Shared RustFS S3-compatible object storage, reached over the shared network at shared-rustfs:9000 (console on 9001)." +provides: rustfs +exports: [host, port, accessKey, secretKey, endpoint] +defaultPort: 9000 +params: + image: + type: string + default: "rustfs/rustfs:latest" + description: "RustFS image. Pin a release tag (e.g. rustfs/rustfs:1.0.0-rc.3) for reproducibility." + rootUser: + type: string + default: devstackadmin + description: "RUSTFS_ACCESS_KEY — matches the minio template's default so the two are drop-in swappable." + rootPassword: + type: string + default: devstackadmin + description: "RUSTFS_SECRET_KEY." + +service: + image: "[[ .params.image ]]" + restart: unless-stopped + environment: + RUSTFS_ACCESS_KEY: "[[ .params.rootUser ]]" + RUSTFS_SECRET_KEY: "[[ .params.rootPassword ]]" + # Set the data volume through the env rather than a command argument: the + # image's ENTRYPOINT is /entrypoint.sh with CMD ["rustfs"], so passing a bare + # path would replace the command instead of adding to it. + RUSTFS_VOLUMES: /data + RUSTFS_ADDRESS: "0.0.0.0:9000" + RUSTFS_CONSOLE_ADDRESS: "0.0.0.0:9001" + RUSTFS_CONSOLE_ENABLE: "true" + volumes: + # The container runs as uid/gid 10001. A named volume is fine — Docker seeds + # ownership from the image — but a host bind mount here would need chowning. + - "rustfsdata:/data" + healthcheck: + # curl is explicitly installed in the alpine runtime stage. + test: ["CMD", "curl", "-fsS", "http://localhost:9000/health"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 15s + +volumes: + rustfsdata: {} diff --git a/templates/smee/build/Dockerfile b/templates/smee/build/Dockerfile new file mode 100644 index 0000000..8272b53 --- /dev/null +++ b/templates/smee/build/Dockerfile @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine + +# probot ships smee-client to npm only — there is no official image, and the +# third-party ones are stale and amd64-only. Building here keeps it current and +# multi-arch. +RUN npm install -g smee-client + +WORKDIR /app +CMD ["smee"] diff --git a/templates/smee/template.yaml b/templates/smee/template.yaml new file mode 100644 index 0000000..456be53 --- /dev/null +++ b/templates/smee/template.yaml @@ -0,0 +1,38 @@ +schemaVersion: 1 +description: "smee.io webhook forwarder — relays public webhook deliveries to a service inside the workspace." +params: + nodeVersion: + type: string + default: "24" + description: "Node major version image tag." + target: + type: string + default: "http://app:3000/webhooks" + description: "Where to POST deliveries, as an in-network URL. Set this to your own service — the default is a placeholder and will just log connection errors. Use the service name, not localhost." + channel: + type: string + default: "" + description: "The smee.io channel URL. Leave empty and smee creates a new channel and prints it in the logs on first start." + +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + environment: + SMEE_URL: "[[ .params.channel ]]" + SMEE_TARGET: "[[ .params.target ]]" + # smee-client is a pure OUTBOUND relay: it opens an SSE connection to smee.io + # and POSTs each delivery to --target. It never binds a listening socket, so + # this service has no port, nothing to EXPOSE and nothing to expose to the host. + # + # There is also nothing to hot-reload — no source, no watcher — so unlike every + # other app template here it takes no bind mount and no polling env. + command: + - "sh" + - "-lc" + - >- + if [ -n "$SMEE_URL" ]; then exec smee --url "$SMEE_URL" --target "$SMEE_TARGET"; + else exec smee --target "$SMEE_TARGET"; fi diff --git a/templates/svelte.kit/build/Dockerfile b/templates/svelte.kit/build/Dockerfile new file mode 100644 index 0000000..dc30719 --- /dev/null +++ b/templates/svelte.kit/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 5173 +CMD ["npm", "run", "dev"] diff --git a/templates/svelte.kit/template.yaml b/templates/svelte.kit/template.yaml new file mode 100644 index 0000000..67f3191 --- /dev/null +++ b/templates/svelte.kit/template.yaml @@ -0,0 +1,31 @@ +schemaVersion: 1 +description: "SvelteKit dev server (vite dev) with HMR." +params: + nodeVersion: + type: string + default: "24" + description: "Node major version image tag." + port: + type: int + default: 5173 + description: "Vite dev server port." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # `npm install` also runs `svelte-kit sync` via npm's prepare lifecycle hook, + # which generates .svelte-kit/ — SvelteKit will not start without it. If you + # switch this to `npm ci --ignore-scripts`, add an explicit `npx svelte-kit sync`. + # + # The bare `--` is load-bearing: npm swallows `--host` otherwise. + command: ["sh", "-lc", "npm install && npm run dev -- --host 0.0.0.0 --port [[ .params.port ]]"] + environment: + NODE_ENV: development + CHOKIDAR_USEPOLLING: "true" + CHOKIDAR_INTERVAL: "300" + volumes: + - "..:/app" + - "/app/node_modules" diff --git a/templates/timescaledb/template.yaml b/templates/timescaledb/template.yaml new file mode 100644 index 0000000..0049a23 --- /dev/null +++ b/templates/timescaledb/template.yaml @@ -0,0 +1,48 @@ +schemaVersion: 1 +description: "Shared TimescaleDB engine (PostgreSQL + time-series), reached over the shared network at shared-timescaledb:5432." +provides: timescaledb +exports: [host, port, user, password, database] +defaultPort: 5432 +params: + version: + type: string + default: "pg18" + description: "Image tag. There is NO :latest on timescale/timescaledb-ha — always a pgNN tag." + rootUser: + type: string + default: devstack + description: "Superuser created on first boot." + rootPassword: + type: string + default: devstack + description: "Superuser password (local development)." + database: + type: string + default: devstack + description: "Database created on first boot." + +service: + image: "timescale/timescaledb-ha:[[ .params.version ]]" + restart: unless-stopped + # POSTGRES_* are fully supported: this entrypoint is an explicit fork of the + # official postgres one. The timescaledb extension is preloaded by the image. + environment: + POSTGRES_USER: "[[ .params.rootUser ]]" + POSTGRES_PASSWORD: "[[ .params.rootPassword ]]" + POSTGRES_DB: "[[ .params.database ]]" + volumes: + # NOT the path the plain postgres template uses. This image is not built on + # docker-library/postgres: PGROOT is /home/postgres and PGDATA has always been + # /home/postgres/pgdata/data, so the PG18 PGDATA move (DECISIONS D8) does not + # apply here. Mount the PARENT — the image pre-chowns the whole tree to the + # non-root postgres user. + - "timescaledata:/home/postgres/pgdata" + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U [[ .params.rootUser ]] || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + timescaledata: {} diff --git a/templates/valkey/template.yaml b/templates/valkey/template.yaml new file mode 100644 index 0000000..f818c7e --- /dev/null +++ b/templates/valkey/template.yaml @@ -0,0 +1,31 @@ +schemaVersion: 1 +description: "Shared Valkey engine (Redis-compatible fork), reached over the shared network at shared-valkey." +provides: valkey +exports: [host, port] +defaultPort: 6379 +params: + version: + type: string + default: "9" + description: "Valkey major version image tag." + +service: + image: "valkey/valkey:[[ .params.version ]]" + restart: unless-stopped + # Explicit argv (not bare flags): the entrypoint only prepends valkey-server for + # a bare -flag/*.conf first arg, and naming the binary keeps the uid-999 drop. + command: ["valkey-server", "--appendonly", "yes"] + volumes: + # The image declares no VOLUME — only WORKDIR /data — so without this mount + # every AOF/RDB file dies with the container. + - "valkeydata:/data" + healthcheck: + # valkey-cli ships in the image (a multi-call symlink of valkey-server); it + # prints PONG and exits non-zero on connection refused. There is no curl here. + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + valkeydata: {} diff --git a/templates/vue.vite/build/Dockerfile b/templates/vue.vite/build/Dockerfile new file mode 100644 index 0000000..dc30719 --- /dev/null +++ b/templates/vue.vite/build/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG NODE_VERSION=[[ .params.nodeVersion ]] +FROM node:${NODE_VERSION}-alpine +WORKDIR /app +EXPOSE 5173 +CMD ["npm", "run", "dev"] diff --git a/templates/vue.vite/template.yaml b/templates/vue.vite/template.yaml new file mode 100644 index 0000000..f68cb5e --- /dev/null +++ b/templates/vue.vite/template.yaml @@ -0,0 +1,32 @@ +schemaVersion: 1 +description: "Vue + Vite dev server with HMR." +params: + nodeVersion: + type: string + default: "24" + description: "Node major version image tag." + port: + type: int + default: 5173 + description: "Vite dev server port." +service: + build: + context: build + dockerfile: Dockerfile + args: + NODE_VERSION: "[[ .params.nodeVersion ]]" + restart: unless-stopped + # The bare `--` is load-bearing: `npm run dev --host` is swallowed by npm and + # never reaches vite, so the server stays on its localhost default and is + # unreachable from outside the container. + command: ["sh", "-lc", "npm install && npm run dev -- --host 0.0.0.0 --port [[ .params.port ]]"] + environment: + NODE_ENV: development + # inotify does not propagate across a 9p/virtiofs bind mount on WSL2 or + # Docker Desktop; Vite's own docs recommend polling there. + CHOKIDAR_USEPOLLING: "true" + CHOKIDAR_INTERVAL: "300" + volumes: + # compose lives in /.devstack, so ".." is the project source. + - "..:/app" + - "/app/node_modules" From 9f79775031d59a6eb98eb43dc23c16104c2b3a64 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 24 Aug 2026 00:46:24 -0300 Subject: [PATCH 2/2] docs(readme): cover the 51-template catalogue and the ai command group The README still described six JS templates and made no mention of the AI-agent surface that landed in #124. * Replaces the framework-templates bullet with the real catalogue: apps across Go, Rust, Python, Node, Bun, Deno, PHP, Ruby, Elixir, Java and .NET plus the Vite family, and a second bullet listing the 25 shared engines. * Adds the `ai` group to the command table and `config schema` alongside `config validate/show`. * Documents that the whole docs corpus is readable from the binary, and links the new AI-agents guide. * Bumps the spec range to 32 and folds spec 32 into the status paragraph. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2cdc928..47c3385 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,19 @@ tasks: - **Active context + switching** — `devstack use ` sets the current project; with shell integration it `cd`s and sets env in your live shell. `devstack context` shows where you are; a prompt segment keeps it visible. -- **Framework templates with hot reload** — `node.express`, `node.nestjs`, - `node.next`, `react.vite`, `bun.app`, `turborepo`, `php.laravel.nginx`, plus the - shared engines. App templates bind-mount your source and run the dev server with - file-watch polling for WSL2. +- **51 built-in templates with hot reload** — apps for Go, Rust, Python + (FastAPI/Django/Flask), Node, Bun, Deno, PHP/Laravel, Ruby on Rails, Elixir + Phoenix, Java Spring, ASP.NET, and the Vite family (React, Vue, Svelte, Nuxt, + Astro). App templates bind-mount your source, force the dev server to bind + `0.0.0.0`, and enable file-watch polling for WSL2. +- **Shared engines for most of what you'd reach for** — Postgres, MySQL, MariaDB, + MongoDB, Cassandra, ArangoDB, Redis, Valkey, TimescaleDB, ClickHouse, Neo4j, + MinIO, RustFS, OpenSearch, Meilisearch, Kafka, NATS, RabbitMQ, etcd, Consul, + Keycloak, Jaeger, Mailpit, Mosquitto, LocalStack. +- **Your AI tools learn devstack from devstack** — `devstack ai install` writes + Claude Code skills, a fenced `AGENTS.md` block (read by Codex, Cursor, Copilot, + Gemini CLI, Windsurf, Zed) and an MCP registration into the repo; `devstack ai + mcp` serves the whole CLI over the Model Context Protocol. - **A task runner** — declare `tasks:` with `deps:` and run the graph with `devstack run ` (dependency-ordered, parallel, streamed). - **Deterministic generation** — templates render Compose + Dockerfiles @@ -111,7 +120,8 @@ tasks: |---|---| | **Lifecycle** | `up` · `down` · `status` · `shell` · `run` · `logs` · `dashboard` | | **Context & DX** | `use` · `context` · `shell-init` · `project list/new` · `env list/set/unset` | -| **Config & templates** | `init` · `config validate/show` · `generate` · `ide` · `template list/lint/test/new` · `import` | +| **Config & templates** | `init` · `config validate/show/schema` · `generate` · `ide` · `template list/lint/test/new` · `import` | +| **AI agents** | `ai install/check` · `ai mcp` · `ai docs` · `ai commands` | | **Shared & host access** | `shared status/gc/doctor` · `expose` · `ports` | | **Data plane** | `db` · `s3` · `queue` · `topic` · `stream` · `resource` · `aws -- …` | | **Multi-repo git** | `ws clone/sync/status/git` | @@ -180,17 +190,28 @@ Run `devstack doctor` to check everything. workspaces, projects, env vars, templates, the data plane, every command, and the full config reference. - **[QUICKSTART.md](docs/QUICKSTART.md)** — the 5-minute path. +- **[AI agents](docs/guide/ai-agents.md)** — the `ai` group: MCP, skills, the + embedded docs corpus and the JSON Schemas. - **[ARCHITECTURE.md](docs/ARCHITECTURE.md)** · **[DECISIONS.md](docs/DECISIONS.md)** · **[ROADMAP.md](docs/ROADMAP.md)** — design, chosen stack, milestones. -- **Component specs** — [`docs/specs/`](docs/specs/) (01…31), each self-contained. +- **Component specs** — [`docs/specs/`](docs/specs/) (01…32), each self-contained. + +You can also read the documentation without leaving the terminal — the whole +corpus is compiled into the binary: + +```bash +devstack ai docs # list every page +devstack ai docs guide/templates # print one +devstack ai docs --search "port" # search it +``` ## Status 🧪 **Beta (0.x).** The shared-services core, the deterministic generation pipeline, -the data plane, multi-repo git, secrets, networking, and the interactive-DX lane -(active context, shell integration, framework/monorepo templates, `devstack run`) -are all implemented and green on `make ci` + `make determinism`. The full -log/dashboard cockpit ([spec 16](docs/specs/16-logs-and-dashboard.md)) is the main -in-flight item. +the data plane, multi-repo git, secrets, networking, the interactive-DX lane +(active context, shell integration, templates, `devstack run`) and the AI-agent +surface ([spec 32](docs/specs/32-ai-agent-integration.md)) are all implemented and +green on `make ci` + `make determinism`. The full log/dashboard cockpit +([spec 16](docs/specs/16-logs-and-dashboard.md)) is the main in-flight item. ## Development