diff --git a/cmd/cli/create.go b/cmd/cli/create.go
index c3c846b7..c515c6de 100644
--- a/cmd/cli/create.go
+++ b/cmd/cli/create.go
@@ -49,9 +49,13 @@ func init() {
createClusterCmd.Flags().String("provider", "kind", "Cluster provider (only 'kind' is supported)")
// Shadow global flags
+ createCmd.PersistentFlags().StringSlice("file", nil, "")
createCmd.PersistentFlags().Bool("debug", false, "")
createCmd.PersistentFlags().String("kubeconfig", "", "")
createCmd.PersistentFlags().Bool("verbose", false, "")
+
+ // Hide them from help output
+ createCmd.PersistentFlags().MarkHidden("file")
createCmd.PersistentFlags().MarkHidden("debug")
createCmd.PersistentFlags().MarkHidden("kubeconfig")
createCmd.PersistentFlags().MarkHidden("verbose")
diff --git a/cmd/cli/create_pattern.go b/cmd/cli/create_pattern.go
new file mode 100644
index 00000000..2e921b5e
--- /dev/null
+++ b/cmd/cli/create_pattern.go
@@ -0,0 +1,115 @@
+//go:build !runtime && !gateway
+
+package cli
+
+import (
+ "fmt"
+ "path/filepath"
+
+ "github.com/orkspace/orkestra/pkg/generate"
+ "github.com/spf13/cobra"
+)
+
+var createPatternCmd = &cobra.Command{
+ Use: "pattern",
+ Short: "Scaffold a new Orkestra pattern: katalog.yaml, simulate.yaml, e2e.yaml, README.md",
+ Long: `Creates the files needed to build, test, and publish an Orkestra pattern.
+
+Always written:
+ katalog.yaml — operator declaration
+ simulate.yaml — in-memory test scaffold (ork simulate)
+ e2e.yaml — real-cluster integration test scaffold (ork e2e)
+ README.md — actionable steps from edit to release
+
+Also written when --typed, --add-hook, or --add-constructor:
+ values.yaml — runtime image (set before ork e2e)
+ Makefile — registry, build, build-runtime, docker, push, release
+ Dockerfile — production container image (distroless, runtime binary only)
+
+Typed mode flags are forwarded to katalog generation:
+ --add-hook Include a hooks section
+ --add-constructor Include a constructor section
+ --typed Include both hooks and constructor (commented)
+
+Examples:
+ ork create pattern
+ ork create pattern --add-hook -o ./my-operator/
+ ork create pattern --typed`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ addHook, _ := cmd.Flags().GetBool("add-hook")
+ addConstructor, _ := cmd.Flags().GetBool("add-constructor")
+ typed, _ := cmd.Flags().GetBool("typed")
+ outputDir, _ := cmd.Flags().GetString("output")
+
+ if outputDir == "" {
+ outputDir = "."
+ }
+
+ isTyped := typed || addHook || addConstructor
+
+ katalogOpts := generate.KatalogScaffoldOptions{
+ AddHook: addHook,
+ AddConstructor: addConstructor,
+ Typed: typed,
+ OutputFile: filepath.Join(outputDir, fileKatalog),
+ }
+ if err := katalogOpts.Validate(); err != nil {
+ return err
+ }
+
+ fmt.Printf("generating pattern scaffold → %s/\n", outputDir)
+
+ if _, err := generate.KatalogScaffold(katalogOpts); err != nil {
+ return fmt.Errorf("generating %s: %w", fileKatalog, err)
+ }
+
+ if err := generate.WriteSimulateScaffold(filepath.Join(outputDir, fileSimulate)); err != nil {
+ return fmt.Errorf("generating %s: %w", fileSimulate, err)
+ }
+
+ if err := generate.WriteE2EScaffold(filepath.Join(outputDir, fileE2e), isTyped); err != nil {
+ return fmt.Errorf("generating %s: %w", fileE2e, err)
+ }
+
+ if err := generate.WriteREADME(filepath.Join(outputDir, fileReadMe), isTyped); err != nil {
+ return fmt.Errorf("generating %s: %w", fileReadMe, err)
+ }
+
+ if isTyped {
+ if err := generate.WriteValuesYAML(filepath.Join(outputDir, fileValues)); err != nil {
+ return fmt.Errorf("generating %s: %w", fileValues, err)
+ }
+ if err := generate.WriteMakefile(filepath.Join(outputDir, fileMakeFile)); err != nil {
+ return fmt.Errorf("generating %s: %w", fileMakeFile, err)
+ }
+ if err := generate.WriteDockerfile(filepath.Join(outputDir, fileDockerfile)); err != nil {
+ return fmt.Errorf("generating %s: %w", fileDockerfile, err)
+ }
+ }
+
+ fmt.Printf("\n→ pattern scaffold written to %s\n", bold(outputDir+"/"))
+ fmt.Printf(" %s %-16s %s\n", successMark(), fileKatalog, dim("declare your CRD(s) and resources"))
+ fmt.Printf(" %s %-16s %s\n", successMark(), fileSimulate, dim("ork simulate"))
+ fmt.Printf(" %s %-16s %s\n", successMark(), fileE2e, dim("ork e2e"))
+ fmt.Printf(" %s %-16s %s\n", successMark(), fileReadMe, dim("start here"))
+ if isTyped {
+ fmt.Printf(" %s %-16s %s\n", successMark(), fileValues, dim("set runtime.image before ork e2e"))
+ fmt.Printf(" %s %-16s %s\n", successMark(), fileMakeFile, dim("make registry, make build, make release"))
+ fmt.Printf(" %s %-16s %s\n", successMark(), fileDockerfile, dim("production container image"))
+ }
+ fmt.Println()
+ return nil
+ },
+}
+
+func init() {
+ createCmd.AddCommand(createPatternCmd)
+ createPatternCmd.Flags().Bool("add-hook", false,
+ "Typed mode: include a hooks section in katalog.yaml (also writes Makefile + Dockerfile)")
+ createPatternCmd.Flags().Bool("add-constructor", false,
+ "Typed mode: include a constructor section in katalog.yaml (also writes Makefile + Dockerfile)")
+ createPatternCmd.Flags().Bool("typed", false,
+ "Typed mode: include both hooks and constructor commented (also writes Makefile + Dockerfile)")
+ createPatternCmd.Flags().StringP("output", "o", "",
+ "Output directory (default: current directory)")
+}
diff --git a/cmd/cli/files.go b/cmd/cli/files.go
index 8a01d339..a8e96e46 100644
--- a/cmd/cli/files.go
+++ b/cmd/cli/files.go
@@ -7,12 +7,16 @@ import (
)
const (
- fileKatalog = "katalog.yaml"
- fileKomposer = "komposer.yaml"
- fileE2e = "e2e.yaml"
- fileSimulate = "simulate.yaml"
- fileCrd = "crd.yaml"
- fileCr = "cr.yaml"
+ fileKatalog = "katalog.yaml"
+ fileKomposer = "komposer.yaml"
+ fileE2e = "e2e.yaml"
+ fileSimulate = "simulate.yaml"
+ fileCrd = "crd.yaml"
+ fileCr = "cr.yaml"
+ fileReadMe = "README.md"
+ fileMakeFile = "Makefile"
+ fileDockerfile = "Dockerfile"
+ fileValues = "values.yaml"
)
// resolveKatalogPaths resolves the katalog file paths in the following order:
diff --git a/documentation/concepts/patterns/index.md b/documentation/concepts/patterns/index.md
index a4b01d08..403fd7c1 100644
--- a/documentation/concepts/patterns/index.md
+++ b/documentation/concepts/patterns/index.md
@@ -48,4 +48,14 @@ This is the same shift containers made for applications. Patterns make operator
YAML files are documents. Patterns are something more specific: they encode a solution to a named problem that recurs in the operator world. The name reflects intent — not format.
+---
+
+## Pages
+
+| Page | What it covers |
+|------|---------------|
+| [Scaffolding a pattern](scaffold.md) | `ork create pattern` — generate the full file set to build, test, and publish |
+
+---
+
→ See the [Pattern kinds in the registry](../../orkestra-registry/index.md) — examples of Katalogs, Motifs, and Komposers in practice.
diff --git a/documentation/concepts/patterns/scaffold.md b/documentation/concepts/patterns/scaffold.md
new file mode 100644
index 00000000..513fb6db
--- /dev/null
+++ b/documentation/concepts/patterns/scaffold.md
@@ -0,0 +1,141 @@
+# Scaffolding a Pattern
+
+`ork create pattern` generates the full file set needed to build, test, and publish an Orkestra pattern. It surfaces the same katalog scaffolding as `ork generate katalog` and adds the testing layer — `simulate.yaml` and `e2e.yaml` — so the operator suite is complete from the start.
+
+```bash
+ork create pattern
+ork create pattern -o ./my-operator/
+ork create pattern --add-hook -o ./my-operator/
+```
+
+---
+
+## What gets generated
+
+```text
+my-operator/
+ katalog.yaml — operator declaration
+ simulate.yaml — in-memory test (ork simulate)
+ e2e.yaml — real-cluster test (ork e2e)
+ README.md — actionable steps from edit to release
+```
+
+In typed mode (`--add-hook`, `--add-constructor`, `--typed`):
+
+```text
+ values.yaml — runtime image (set before ork e2e)
+ Makefile — registry, build, build-runtime, docker, push, release
+ Dockerfile — production container image (distroless, runtime binary only)
+```
+
+---
+
+## Dynamic vs typed
+
+The typed flags are forwarded directly to katalog generation — they behave identically to `ork generate katalog`.
+
+| Flag | Katalog mode | Extra files |
+|------|-------------|-------------|
+| *(none)* | Dynamic — declarative templates only | — |
+| `--add-hook` | Typed — commented `hooks` declaration | `values.yaml`, `Makefile`, `Dockerfile` |
+| `--add-constructor` | Typed — commented `constructor` declaration | `values.yaml`, `Makefile`, `Dockerfile` |
+| `--typed` | Typed — both sections commented, pick one | `values.yaml`, `Makefile`, `Dockerfile` |
+
+---
+
+## From scaffold to running operator
+
+### Dynamic mode
+
+```bash
+# 1. Generate
+ork create pattern -o ./my-operator/
+cd my-operator/
+
+# 2. Edit katalog.yaml — fill in spec.crds, declare resources
+# 3. Validate
+ork validate
+
+# 4. Simulate — no cluster needed
+ork simulate
+
+# 5. Run locally
+ork run --dev
+
+# 6. E2E test
+ork e2e
+
+# 7. Observe
+ork control
+```
+
+### Typed mode
+
+```bash
+# 1. Generate
+ork create pattern --add-hook -o ./my-operator/
+cd my-operator/
+
+# 2. Edit katalog.yaml — fill in apiTypes
+# 3. Generate type registry
+make registry
+
+# 4. Write your hook function
+
+# 5. Release the image
+make release IMAGE=ghcr.io/myorg/my-operator:v0.1.0
+
+# 6. Set the image in values.yaml
+# runtime.image.repository: ghcr.io/myorg/my-operator
+# runtime.image.tag: v0.1.0
+
+# 7. Validate
+ork validate
+
+# 8. Simulate
+ork simulate
+
+# 9. Run locally
+ork run --dev
+
+# 10. E2E test — passes values.yaml to the Orkestra Helm chart
+ork e2e
+
+# 11. Push
+ork push
+```
+
+---
+
+## The testing layer
+
+`simulate.yaml` and `e2e.yaml` are starters — they reference `./katalog.yaml` and `./cr.yaml` but have no assertions yet.
+
+Add assertions before running, or generate them from a live run:
+
+```bash
+ork simulate init
+```
+
+See [Simulate](../simulate/index.md) and [E2E](../e2e/index.md) for the full test authoring guide.
+
+---
+
+## Publishing
+
+Once your tests pass, push to the registry:
+
+```bash
+ork push
+```
+
+In typed mode, release the image first, then update `values.yaml` so `ork e2e` can pass it to the Orkestra Helm chart, then push the katalog:
+
+```bash
+make release IMAGE=ghcr.io/myorg/my-operator:v0.1.0
+# edit values.yaml: set runtime.image.repository and runtime.image.tag
+ork e2e
+ork push
+```
+
+→ See [`ork create pattern` CLI reference](../../reference/cli/create.md#ork-create-pattern)
diff --git a/documentation/reference/cli/create.md b/documentation/reference/cli/create.md
index 1ace4e09..c0dc890d 100644
--- a/documentation/reference/cli/create.md
+++ b/documentation/reference/cli/create.md
@@ -47,6 +47,73 @@ ork create cluster --name ork-e2e
---
+### `ork create pattern`
+
+Scaffold a complete Orkestra pattern directory: `katalog.yaml`, `simulate.yaml`, `e2e.yaml`, and `README.md`. In typed mode, also writes `values.yaml`, `Makefile`, and `Dockerfile`.
+
+```bash
+ork create pattern [flags]
+```
+
+#### Flags
+
+| Flag | Description |
+|------|-------------|
+| `--add-hook` | Typed mode: include a `hooks` section in `katalog.yaml` (also writes `values.yaml`, `Makefile`, `Dockerfile`) |
+| `--add-constructor` | Typed mode: include a `constructor` section in `katalog.yaml` (also writes `values.yaml`, `Makefile`, `Dockerfile`) |
+| `--typed` | Typed mode: include both sections commented (also writes `values.yaml`, `Makefile`, `Dockerfile`) |
+| `-o, --output
` | Output directory (default: current directory) |
+
+#### Files written
+
+| File | Always | Typed mode only |
+|------|--------|-----------------|
+| `katalog.yaml` | Yes | — |
+| `simulate.yaml` | Yes | — |
+| `e2e.yaml` | Yes | — |
+| `README.md` | Yes | — |
+| `values.yaml` | — | Yes |
+| `Makefile` | — | Yes |
+| `Dockerfile` | — | Yes |
+
+#### Examples
+
+Scaffold a dynamic-mode pattern in the current directory:
+
+```bash
+ork create pattern
+```
+
+Scaffold into a new directory:
+
+```bash
+ork create pattern -o ./my-operator/
+```
+
+Typed mode with hooks:
+
+```bash
+ork create pattern --add-hook -o ./my-operator/
+```
+
+Typed mode with both sections (choose one after generation):
+
+```bash
+ork create pattern --typed
+```
+
+#### Behavior
+
+- `katalog.yaml` is generated by the same engine as `ork generate katalog` — the typed flags behave identically.
+- `simulate.yaml` and `e2e.yaml` are minimal starters that reference `./katalog.yaml` and `./cr.yaml`. Edit assertions before running.
+- `e2e.yaml` in typed mode includes `valuesFiles: [./values.yaml]` — the values file is how `ork e2e` passes the runtime image to the Orkestra Helm chart.
+- `values.yaml` sets `runtime.image.repository` and `runtime.image.tag`. Update these after `make release` before running `ork e2e`.
+- `README.md` contains numbered, actionable steps from edit to release. Typed mode adds the registry generation and build steps.
+- `Makefile` targets: `registry`, `build`, `build-runtime`, `validate`, `simulate`, `e2e`, `docker`, `push`, `release`, `clean`.
+- `Dockerfile` builds a production image using the runtime binary only (distroless base, no shell).
+
+---
+
## Notes
- `ork create cluster` is intended for local development and CI environments, not production.
diff --git a/pkg/generate/katalog_generator.go b/pkg/generate/katalog_generator.go
index 6cad2088..ec330441 100644
--- a/pkg/generate/katalog_generator.go
+++ b/pkg/generate/katalog_generator.go
@@ -26,6 +26,7 @@ import (
"errors"
"fmt"
"os"
+ "path/filepath"
"strings"
"text/template"
"time"
@@ -186,6 +187,9 @@ func KatalogScaffold(opts KatalogScaffoldOptions) (string, error) {
if dest == "" {
dest = "katalog.yaml"
}
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return "", fmt.Errorf("creating directory for %s: %w", dest, err)
+ }
if err := os.WriteFile(dest, []byte(out), 0o644); err != nil {
return "", fmt.Errorf("writing %s: %w", dest, err)
}
@@ -197,6 +201,7 @@ func KatalogScaffold(opts KatalogScaffoldOptions) (string, error) {
// Template syntax appearing inside YAML comments is escaped via {{ "{{" }}.
var katalogTmpl = template.Must(template.New("katalog-scaffold").Parse(
`# Generated by "ork generate katalog{{ .FlagSuffix }}" on {{ .Timestamp }}
+# https://orkestra.sh/docs/reference/schema/katalog/
# Edit every TODO placeholder before running "ork run".
apiVersion: orkestra.orkspace.io/v1
kind: Katalog
@@ -278,24 +283,25 @@ spec:
queue:
maxDepth: 100
operatorBox:
- default: {{ if .DefaultFalse }}false{{ else }}true{{ end }}
+ reconciler:
+ default: {{ if .DefaultFalse }}false{{ else }}true{{ end }}
{{ if .ShowHooks }}
- # hooks:
- # # Package exporting the hook factory function.
- # location: github.com/myorg/my-operator/hooks
- # # Function signature: func() domain.AnyReconcileHooks
- # # Return: domain.ReconcileHooks[*MyKind]{OnReconcile: ...}
- # function: MyResourceHooks
- # alias: myhook
+ # hooks:
+ # # Package exporting the hook factory function.
+ # location: github.com/myorg/my-operator/hooks
+ # # Function signature: func() domain.AnyReconcileHooks
+ # # Return: domain.ReconcileHooks[*MyKind]{OnReconcile: ...}
+ # function: MyResourceHooks
+ # alias: myhook
{{ end -}}
{{ if .ShowConstructor }}
- # constructor:
- # # Package exporting the reconciler constructor.
- # location: github.com/myorg/my-operator/reconciler
- # # Function signature:
- # # func(*kubeclient.Kubeclient, cache.SharedIndexInformer, *event.Event) domain.Reconciler
- # function: NewMyResourceReconciler
- # alias: myrec
+ # constructor:
+ # # Package exporting the reconciler constructor.
+ # location: github.com/myorg/my-operator/reconciler
+ # # Function signature:
+ # # func(*kubeclient.Kubeclient, cache.SharedIndexInformer, *event.Event) domain.Reconciler
+ # function: NewMyResourceReconciler
+ # alias: myrec
{{ end -}}
{{ if not .IsTyped }}
# Declarative template blocks — uncomment and fill in what you need.
diff --git a/pkg/generate/pattern_generator.go b/pkg/generate/pattern_generator.go
new file mode 100644
index 00000000..1bf7746a
--- /dev/null
+++ b/pkg/generate/pattern_generator.go
@@ -0,0 +1,83 @@
+// pkg/generate/pattern_generator.go
+package generate
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "text/template"
+)
+
+// WriteSimulateScaffold writes a simulate.yaml starter to dest.
+func WriteSimulateScaffold(dest string) error {
+ return writeStaticTemplate("templates/pattern_simulate.tmpl", dest)
+}
+
+// WriteE2EScaffold writes an e2e.yaml starter to dest.
+// When typed is true, a valuesFiles entry referencing values.yaml is included.
+func WriteE2EScaffold(dest string, typed bool) error {
+ tmpl, err := template.ParseFS(templateFS, "templates/pattern_e2e.tmpl")
+ if err != nil {
+ return fmt.Errorf("parsing e2e template: %w", err)
+ }
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, patternReadmeData{Typed: typed}); err != nil {
+ return fmt.Errorf("rendering e2e: %w", err)
+ }
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return fmt.Errorf("creating directory for %q: %w", dest, err)
+ }
+ return os.WriteFile(dest, buf.Bytes(), 0o644)
+}
+
+// WriteValuesYAML writes a values.yaml with the runtime image placeholder to dest.
+func WriteValuesYAML(dest string) error {
+ return writeStaticTemplate("templates/pattern_values.tmpl", dest)
+}
+
+// WriteMakefile writes a clean Makefile (no example-pack workarounds) to dest.
+func WriteMakefile(dest string) error {
+ return writeStaticTemplate("templates/pattern_makefile.tmpl", dest)
+}
+
+// WriteDockerfile writes the production Dockerfile to dest.
+func WriteDockerfile(dest string) error {
+ return writeStaticTemplate("templates/pattern_dockerfile.tmpl", dest)
+}
+
+type patternReadmeData struct {
+ Typed bool
+}
+
+// WriteREADME writes a README.md with actionable steps to dest.
+// When typed is true, the steps include make registry, build, and release.
+func WriteREADME(dest string, typed bool) error {
+ tmpl, err := template.ParseFS(templateFS, "templates/pattern_readme.tmpl")
+ if err != nil {
+ return fmt.Errorf("parsing readme template: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, patternReadmeData{Typed: typed}); err != nil {
+ return fmt.Errorf("rendering readme: %w", err)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return fmt.Errorf("creating directory for %q: %w", dest, err)
+ }
+ return os.WriteFile(dest, buf.Bytes(), 0o644)
+}
+
+// writeStaticTemplate reads a template file from the embedded FS and writes it
+// to dest as-is — no template execution, raw bytes only.
+func writeStaticTemplate(name, dest string) error {
+ content, err := templateFS.ReadFile(name)
+ if err != nil {
+ return fmt.Errorf("reading embedded %q: %w", name, err)
+ }
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return fmt.Errorf("creating directory for %q: %w", dest, err)
+ }
+ return os.WriteFile(dest, content, 0o644)
+}
diff --git a/pkg/generate/templates/pattern_dockerfile.tmpl b/pkg/generate/templates/pattern_dockerfile.tmpl
new file mode 100644
index 00000000..53083ae7
--- /dev/null
+++ b/pkg/generate/templates/pattern_dockerfile.tmpl
@@ -0,0 +1,4 @@
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY ork /usr/local/bin/ork
+USER 65532:65532
+ENTRYPOINT ["/usr/local/bin/ork"]
diff --git a/pkg/generate/templates/pattern_e2e.tmpl b/pkg/generate/templates/pattern_e2e.tmpl
new file mode 100644
index 00000000..26ddb202
--- /dev/null
+++ b/pkg/generate/templates/pattern_e2e.tmpl
@@ -0,0 +1,25 @@
+# https://orkestra.sh/docs/reference/schema/e2e/
+apiVersion: orkestra.orkspace.io/v1
+kind: E2E
+metadata:
+ name: # TODO: operator name
+ description: "End-to-end test for "
+spec:
+ katalog: ./katalog.yaml
+ crd: ./crd.yaml
+ cr: ./cr.yaml
+{{- if .Typed}}
+ valuesFiles:
+ - ./values.yaml
+{{- end}}
+ # timeout: 120s
+ expect:
+ # TODO: add assertions
+ # - name: Deployment ready
+ # after: cr-applied
+ # timeout: 60s
+ # resources:
+ # - kind: Deployment
+ # name: "{{ "{{" }} .metadata.name {{ "}}" }}"
+ # namespace: "{{ "{{" }} .metadata.namespace {{ "}}" }}"
+ # ready: true
diff --git a/pkg/generate/templates/pattern_makefile.tmpl b/pkg/generate/templates/pattern_makefile.tmpl
new file mode 100644
index 00000000..20951d43
--- /dev/null
+++ b/pkg/generate/templates/pattern_makefile.tmpl
@@ -0,0 +1,105 @@
+# ── Typed Orkestra Operator ────────────────────────────────────────────────────
+BINARY_NAME ?= ork
+DEV_OUTPUT_DIR ?= $(HOME)/.orkestra/bin
+PROD_OUTPUT_DIR ?= $(HOME)/.orkestra/bin/runtime
+KATALOG ?= katalog.yaml
+
+IMAGE_REPO ?= myorg/my-operator
+IMAGE_TAG ?= latest
+IMAGE ?= $(IMAGE_REPO):$(IMAGE_TAG)
+BUILD_TAGS ?=
+
+GOOS ?= linux
+GOARCH ?= amd64
+CGO_ENABLED = 0
+
+ORK_LDFLAGS := -X github.com/orkspace/orkestra/pkg/version.Version=$(GIT_VERSION) \
+ -X github.com/orkspace/orkestra/pkg/version.Commit=$(GIT_COMMIT) \
+ -X github.com/orkspace/orkestra/pkg/version.Date=$(GIT_DATE)
+
+# ── registry ──────────────────────────────────────────────────────────────────
+# Generates pkg/typeregistry/zz_generated_typeregistry.go.
+# Re-run whenever you change apiTypes fields in katalog.yaml.
+.PHONY: registry
+registry:
+ ork generate registry --file $(KATALOG)
+
+# ── build (development) ───────────────────────────────────────────────────────
+# Builds the full CLI with all commands (validate, e2e, simulate, run, etc.)
+.PHONY: build
+build:
+ @mkdir -p $(DEV_OUTPUT_DIR)
+ go mod tidy
+ gofmt -w .
+ go build \
+ -ldflags "$(ORK_LDFLAGS)" \
+ -o $(DEV_OUTPUT_DIR)/$(BINARY_NAME) ./cmd/orkestra
+ @echo "✅ Development build: $(DEV_OUTPUT_DIR)/$(BINARY_NAME)"
+
+# ── build-runtime (production) ────────────────────────────────────────────────
+# Builds a production binary with ONLY the `run` command (no validate, e2e, etc.)
+# This is what runs in your cluster — smaller attack surface, focused responsibility.
+.PHONY: build-runtime
+build-runtime:
+ @mkdir -p $(PROD_OUTPUT_DIR)
+ go mod tidy
+ gofmt -w .
+ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \
+ -tags "runtime" \
+ -ldflags "$(ORK_LDFLAGS)" \
+ -o $(PROD_OUTPUT_DIR)/$(BINARY_NAME) ./cmd/orkestra
+ @echo "✅ Production runtime build: $(PROD_OUTPUT_DIR)/$(BINARY_NAME)"
+ @echo " This binary only supports 'ork run' — perfect for production."
+
+# ── validate ──────────────────────────────────────────────────────────────────
+.PHONY: validate
+validate:
+ $(DEV_OUTPUT_DIR)/$(BINARY_NAME) validate -f $(KATALOG)
+
+# ── simulate ──────────────────────────────────────────────────────────────────
+.PHONY: simulate
+simulate:
+ $(DEV_OUTPUT_DIR)/$(BINARY_NAME) simulate
+
+# ── e2e ───────────────────────────────────────────────────────────────────────
+.PHONY: e2e
+e2e:
+ $(DEV_OUTPUT_DIR)/$(BINARY_NAME) e2e
+
+# ── docker / push / release ───────────────────────────────────────────────────
+# Copies the production runtime binary into the current directory for docker build,
+# then removes it. The Docker image contains ONLY the production binary (runtime tag).
+.PHONY: docker
+docker: build-runtime
+ @cp $(PROD_OUTPUT_DIR)/$(BINARY_NAME) ./$(BINARY_NAME)
+ docker build -t $(IMAGE) .
+ @rm -f ./$(BINARY_NAME)
+ @echo "✅ Docker image built: $(IMAGE)"
+
+.PHONY: push
+push:
+ docker push $(IMAGE)
+
+.PHONY: release
+release: docker push
+
+# ── clean ─────────────────────────────────────────────────────────────────────
+.PHONY: clean
+clean:
+ @rm -f $(DEV_OUTPUT_DIR)/$(BINARY_NAME)
+ @rm -rf $(PROD_OUTPUT_DIR)
+ @echo "✅ Removed all local builds"
+
+# ── help ──────────────────────────────────────────────────────────────────────
+.PHONY: help
+help:
+ @echo " registry generate type registry from Katalog"
+ @echo " build compile full development CLI (all commands)"
+ @echo " build-runtime compile production binary (only 'ork run')"
+ @echo " validate run katalog validation (using development binary)"
+ @echo " e2e run end‑to‑end tests (using development binary)"
+ @echo " simulate run simulation tests (using development binary)"
+ @echo " docker build-runtime + copy into Docker image"
+ @echo " push push Docker image to registry"
+ @echo " release docker + push"
+ @echo " clean remove all local builds"
diff --git a/pkg/generate/templates/pattern_readme.tmpl b/pkg/generate/templates/pattern_readme.tmpl
new file mode 100644
index 00000000..2b71e3e9
--- /dev/null
+++ b/pkg/generate/templates/pattern_readme.tmpl
@@ -0,0 +1,100 @@
+# My Operator
+
+Generated by `ork create pattern`.
+
+## Step 1 — Edit katalog.yaml
+
+Fill in your CRD location and fields:
+
+- `spec.crds..crdFile` — path to your CRD file
+- `spec.crds..crFiles` — path to your example CR
+- `operatorBox.onCreate` — resources to create on each CR
+
+> **Before publishing or running in production:** replace `crdFile` and `crFiles`
+> with `apiTypes` (group, version, kind, plural). `crdFile`/`crFiles` are
+> development-only — the runtime reads them from disk; `apiTypes` references a
+> CRD already installed in the cluster and is what belongs in a published Katalog.
+{{if .Typed}}
+## Step 2 — Generate type registry
+
+```bash
+make registry
+```
+
+Re-run whenever you change `apiTypes` fields in `katalog.yaml`.
+
+## Step 3 — Write your hook or constructor
+
+Open the generated stub and implement the function body. The signature is generated — fill in the logic.
+
+## Step 4 — Release the image
+
+```bash
+make release IMAGE=ghcr.io/myorg/my-operator:v0.1.0
+```
+
+## Step 5 — Set the image in values.yaml
+
+Update `values.yaml` to match the image you just released:
+
+```yaml
+runtime:
+ image:
+ repository: ghcr.io/myorg/my-operator
+ tag: v0.1.0
+```
+
+`ork e2e` passes this file to the Orkestra Helm chart so it deploys your operator image.
+
+## Step 6 — Validate
+
+```bash
+make validate
+```
+{{else}}
+## Step 2 — Validate
+
+```bash
+ork validate
+```
+{{end}}
+## Step {{if .Typed}}7{{else}}3{{end}} — Simulate
+
+```bash
+ork simulate
+```
+
+Runs without a cluster. Edit `simulate.yaml` assertions first, or generate them from a live run:
+
+```bash
+ork simulate init
+```
+
+## Step {{if .Typed}}8{{else}}4{{end}} — Run locally
+
+```bash
+ork run --dev
+```
+
+`--dev` spins up a local kind cluster. Skip it if you already have one.
+
+## Step {{if .Typed}}9{{else}}5{{end}} — E2E test
+
+```bash
+ork e2e
+```
+
+## Step {{if .Typed}}10{{else}}6{{end}} — Observe
+
+```bash
+ork control
+```
+
+Open http://localhost:8081. Login with username `orkestra`, password `orkestra`.
+{{if .Typed}}
+## Step 11 — Push
+
+```bash
+ork push
+```
+{{end}}
diff --git a/pkg/generate/templates/pattern_simulate.tmpl b/pkg/generate/templates/pattern_simulate.tmpl
new file mode 100644
index 00000000..71d7072c
--- /dev/null
+++ b/pkg/generate/templates/pattern_simulate.tmpl
@@ -0,0 +1,16 @@
+# https://orkestra.sh/docs/reference/schema/simulate/
+apiVersion: orkestra.orkspace.io/v1
+kind: Simulate
+metadata:
+ name: # TODO: operator name
+spec:
+ katalog: ./katalog.yaml
+ cr: ./cr.yaml
+ expect:
+ # TODO: add assertions — or generate them from a live run:
+ # ork simulate init
+ #
+ # - cycle: 1
+ # verb: create
+ # resource: deployments
+ # name: "{{ .metadata.name }}"
diff --git a/pkg/generate/templates/pattern_values.tmpl b/pkg/generate/templates/pattern_values.tmpl
new file mode 100644
index 00000000..190ae7a0
--- /dev/null
+++ b/pkg/generate/templates/pattern_values.tmpl
@@ -0,0 +1,4 @@
+runtime:
+ image:
+ repository: ghcr.io/myorg/my-operator
+ tag: latest
diff --git a/pkg/runners/configmaps.go b/pkg/runners/configmaps.go
index b01cc828..bf299f3e 100644
--- a/pkg/runners/configmaps.go
+++ b/pkg/runners/configmaps.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_configmaps.go
+// pkg/runners/configmaps.go
package runners
import (
diff --git a/pkg/runners/cronjobs.go b/pkg/runners/cronjobs.go
index 459232ec..e65f2409 100644
--- a/pkg/runners/cronjobs.go
+++ b/pkg/runners/cronjobs.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_cronjobs.go
+// pkg/runners/cronjobs.go
package runners
import (
diff --git a/pkg/runners/deployments.go b/pkg/runners/deployments.go
index 0a025e16..23f12118 100644
--- a/pkg/runners/deployments.go
+++ b/pkg/runners/deployments.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_deployments.go
+// pkg/runners/deployments.go
package runners
import (
diff --git a/pkg/runners/hpas.go b/pkg/runners/hpas.go
index 0d1adc53..8f3b327d 100644
--- a/pkg/runners/hpas.go
+++ b/pkg/runners/hpas.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_hpas.go
+// pkg/runners/hpas.go
package runners
import (
diff --git a/pkg/runners/ingresses.go b/pkg/runners/ingresses.go
index e10f777b..44ce28e9 100644
--- a/pkg/runners/ingresses.go
+++ b/pkg/runners/ingresses.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_ingresses.go
+// pkg/runners/ingresses.go
package runners
import (
diff --git a/pkg/runners/jobs.go b/pkg/runners/jobs.go
index a23a2b51..3108946f 100644
--- a/pkg/runners/jobs.go
+++ b/pkg/runners/jobs.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_jobs.go
+// pkg/runners/jobs.go
package runners
import (
diff --git a/pkg/runners/namespaces.go b/pkg/runners/namespaces.go
index dd5e509c..2e11947c 100644
--- a/pkg/runners/namespaces.go
+++ b/pkg/runners/namespaces.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_namespace.go
+// pkg/runners/namespaces.go
package runners
import (
diff --git a/pkg/runners/pdbs.go b/pkg/runners/pdbs.go
index 68bdb64d..8f2350f2 100644
--- a/pkg/runners/pdbs.go
+++ b/pkg/runners/pdbs.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_pdbs.go
+// pkg/runners/pdbs.go
package runners
import (
diff --git a/pkg/runners/pods.go b/pkg/runners/pods.go
index 9ba65ff6..3ab7163f 100644
--- a/pkg/runners/pods.go
+++ b/pkg/runners/pods.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_pods.go
+// pkg/runners/pods.go
package runners
import (
diff --git a/pkg/runners/pvcs.go b/pkg/runners/pvcs.go
index 3920d95d..a53e8817 100644
--- a/pkg/runners/pvcs.go
+++ b/pkg/runners/pvcs.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_pvcs.go
+// pkg/runners/pvcs.go
package runners
import (
diff --git a/pkg/runners/pvs.go b/pkg/runners/pvs.go
index 7ec759f6..a7b025c6 100644
--- a/pkg/runners/pvs.go
+++ b/pkg/runners/pvs.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_pvs.go
+// pkg/runners/pvs.go
package runners
import (
diff --git a/pkg/runners/replicasets.go b/pkg/runners/replicasets.go
index cd2c1eb0..6f233308 100644
--- a/pkg/runners/replicasets.go
+++ b/pkg/runners/replicasets.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_replicasets.go
+// pkg/runners/replicasets.go
package runners
import (
diff --git a/pkg/runners/rolebindings.go b/pkg/runners/rolebindings.go
index 124efd52..29c9e5b9 100644
--- a/pkg/runners/rolebindings.go
+++ b/pkg/runners/rolebindings.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_rolebindings.go
+// pkg/runners/rolebindings.go
package runners
import (
diff --git a/pkg/runners/roles.go b/pkg/runners/roles.go
index e3a911cf..a8806703 100644
--- a/pkg/runners/roles.go
+++ b/pkg/runners/roles.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_roles.go
+// pkg/runners/roles.go
package runners
import (
diff --git a/pkg/runners/secret_tls.go b/pkg/runners/secret_tls.go
index ed3329e4..4a6b1d68 100644
--- a/pkg/runners/secret_tls.go
+++ b/pkg/runners/secret_tls.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_secrets_tls.go
+// pkg/runners/secret_tls.go
//
// TLS certificate generation and secret rotation for Orkestra secrets.
//
diff --git a/pkg/runners/secrets.go b/pkg/runners/secrets.go
index 1d294012..1c19db86 100644
--- a/pkg/runners/secrets.go
+++ b/pkg/runners/secrets.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_secrets.go
+// pkg/runners/secrets.go
//
// Adds to the previous version:
// - orktypes.EvaluateWhen instead of evaluateConditions (fixes anyOf: being ignored)
diff --git a/pkg/runners/secrets_once.go b/pkg/runners/secrets_once.go
index a6ed8a3e..4dd41126 100644
--- a/pkg/runners/secrets_once.go
+++ b/pkg/runners/secrets_once.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_secrets_once.go
+// pkg/runners/secrets_once.go
//
// once: true on secrets — idempotent random secret generation.
//
diff --git a/pkg/runners/serviceaccounts.go b/pkg/runners/serviceaccounts.go
index e334fe2b..b8b35e7e 100644
--- a/pkg/runners/serviceaccounts.go
+++ b/pkg/runners/serviceaccounts.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_serviceaccounts.go
+// pkg/runners/serviceaccounts.go
package runners
import (
diff --git a/pkg/runners/services.go b/pkg/runners/services.go
index 882c9649..628bcdf1 100644
--- a/pkg/runners/services.go
+++ b/pkg/runners/services.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_services.go
+// pkg/runners/services.go
package runners
import (
diff --git a/pkg/runners/statefulsets.go b/pkg/runners/statefulsets.go
index 7494f219..047db991 100644
--- a/pkg/runners/statefulsets.go
+++ b/pkg/runners/statefulsets.go
@@ -1,4 +1,4 @@
-// pkg/reconciler/run_statefulsets.go
+// pkg/runners/statefulsets.go
package runners
import (