Documentation
¶
Overview ¶
`forge generate accept-fork <path>...` — DEPRECATED alias for `forge disown`.
The fork concept is gone: there is no forked-but-maybe-reconciled- later limbo any more, only forge-owned (Tier-1) and user-owned (Tier-2), with `forge disown` as the one-way door between them. accept-fork survives one release so existing scripts and muscle memory don't break overnight; it simply forwards to the disown flow (and therefore now REQUIRES --reason, like disown does).
Package cli — `forge api` command surface.
`forge api curl <service.method>` prints a copy-pasteable curl invocation for a Connect RPC endpoint. Connect handlers already speak plain HTTP/1.1 POST with application/json — no gRPC tooling needed — but the URL shape and Content-Type rules are undocumented in most projects. This command removes the discovery friction: read the service port from forge.yaml, look up the method's input message in forge_descriptor.json, and emit a curl command with a request-body skeleton populated from zero values for each field.
Streaming RPCs are flagged but still printed — the body shape is the same; only the Content-Type changes to application/connect+json. We surface the difference rather than reject the command so users can hit streaming endpoints too (curl will get the first frame; this is enough to verify reachability and auth).
Package cli — `forge audit` external-builds category (Phase 3 of the build_cmd / external-build escape-hatch refactor).
Surfaces every KCL service whose effective build is a ShellBuild. For each such service the category emits:
- the resolved build_cwd state (exists / missing on disk),
- the last build state from buildtarget.ReadState (image, tag, pushed_at) for every env that recorded one,
- any build_env keys that conflict with a built-in substitution token (IMAGE / TAG / SERVICE / PROJECT_DIR / REGISTRY / TARGETARCH / BUILD_CWD).
Sub-agents and CI dashboards can branch on `.external_builds.status == "warn"` (or "error") directly. The category honours the additive-extension contract from the audit-json skill — a new field on the per-service detail map is additive; consumers that don't read the new key are unaffected.
We render the dev env to enumerate services (matching auditIngress's approach) because that's the only env every project is guaranteed to have. The per-service ReadState loop walks every env declared under deploy/kcl/<env>/main.k so the snapshot reflects what each env last built, not just dev.
Status semantics:
- ok — no services declare a ShellBuild, OR every service has a present build_cwd, no env-key conflicts, and at least one env has recorded state (or no envs at all).
- warn — at least one service has a missing build_cwd on disk, OR at least one service declares a build_env key that collides with a built-in token. Neither blocks a build — skip-with-warn for cwd is the documented contract, and the built-in wins on conflict — but both are surface-up worthy.
- error — KCL render itself failed AND we know features.build is on (project intends to build something; we can't see whether external builds are configured). Degrades to warn when the failure is environmental (kcl not on PATH, no deploy/kcl/dev dir).
Package cli — `forge audit` ingress category (KCL-entity-typed half).
auditIngress / ingressBackendNames / crossCheckIngress stay in package cli because they depend on the KCL render + entity structs (GatewayEntity / HTTPRouteEntity / GRPCRouteEntity) shared by ~12 cli files (build/deploy/dev/doctor). The audit command group (internal/cli/audit) reaches auditIngress through factory.AuditAPI.Ingress, so it never touches these entity types. They return the neutral audittype.Category the group consumes.
Package cli — `forge audit` prerequisites category.
Surfaces the env's DECLARED external prerequisites (forge.ExternalSecret / forge.DNSRecord on Bundle.required_secrets / required_dns) — the out-of-band facts a deploy depends on but forge does NOT create. Today these prereqs live only in KCL docstrings, so `forge deploy` renders green and THEN cert-manager's ACME challenge or the workspace-proxy DNS hangs silently because the prereq was never satisfied. Modeling them as first-class entities turns the docstring into:
- a render-time CHECKLIST (this audit category + the deploy banner),
- a deploy PREFLIGHT block on a declared-required-but-absent ExternalSecret (see cluster.Preflight / RequiredSecrets), and
- a cross-secret BYTE-MATCH consistency check: ExternalSecrets sharing a `value_group` carry the SAME logical value; this category reports the group membership (the KCL schema already blocks a group whose members declare different key sets, and the live byte-compare runs in the preflight).
Like auditIngress this renders the dev env (the only env every project is guaranteed to have) purely to ENUMERATE the declarations; the category is informational (status ok/warn), never an error — a declared prereq is a good thing, and whether it's SATISFIED is a live-cluster question the deploy preflight answers, not a static audit.
build_external.go is the SINGLE dispatcher for KCL Services whose effective build is a ShellBuild (`build = forge.ShellBuild { cmd, cwd, env }`) — the one shell escape hatch. Mirrors the deploytarget/External provider on the build side — same `sh -c` shape, same ${X} substitution, same fail-when-cwd-missing contract. See internal/buildtarget for the runner + spec types.
The dispatcher's responsibilities are deliberately narrow:
- Iterate KCL services whose effective build is a ShellBuild (EffectiveBuildCmd != "").
- Build a Spec from KCL fields + the build-loop's resolved tag / registry / target arch.
- Run Spec through buildtarget.Runner.Build.
- Persist per-service state when the build succeeded so a subsequent `forge deploy <env>` can pin the same tag.
Build-side ownership boundary: the user's BuildCmd owns BOTH the build AND the push (the user composes `docker build … && docker push …` into one string). Forge does NOT run docker push afterwards. This matches the External (deploy) provider's "user owns the CLI" contract — one mental model across both escape hatches.
Package cli — declarative cluster reconcile for `forge up`.
This file generalizes the dev-only, single-cluster ensureDevCluster bootstrap into a declarative LIST: an env declares `Bundle.clusters = [forge.Cluster {...}, ...]` and forge ensures each exists at the head of `forge up` (create-if-absent, no-op if present).
Multi-cluster ownership is a REFERENCE. There is no "primary" cluster: a secondary cluster names its `owner` Cluster, and the KCL render layer DERIVES the joined docker network (Cluster.Network = `k3d-<owner.name>`) and the registry-inherit flag (Cluster.RegistryInherit = true) from that one edge. The owner cluster projects neither — k3d creates its own network/registry. There is no most-X heuristic.
Package cli — standalone k3d registry lifecycle for declared clusters.
A k3d "Simple" config can reference a registry in two ways:
- registries.create — k3d creates a registry container OWNED BY the cluster. `k3d cluster delete` destroys it. Every cold recreate then rebuilds + re-pushes every image (the fat ones especially).
- registries.use — k3d references an EXISTING registry container. A standalone registry (`k3d registry create`) is owned by NO cluster, so it survives `cluster delete` and its pushed images persist across cold recreates.
This file makes the persistent path zero-glue: when a declared cluster's k3d config references a registry via `registries.use`, forge ensures that standalone registry exists BEFORE `k3d cluster create` runs. Idempotent — an already-present registry is a no-op; an absent one is created with the declared host port so host pushes keep resolving to the same `localhost: <port>` ref.
Package cli — `forge delete service` — the inverse of `forge add service`.
Where `forge add service` scaffolds a handlers/<svc>/ dir, appends a component to components.json, and (after the user lists its serviceRow line) serves it, `forge delete service` walks that back:
- Removes the component from components.json so forge stops treating it as part of the project shape.
- Removes the handler scaffold directory (handlers/<svc>/).
- Leaves a TYPES-ONLY tombstone comment in pkg/app/services.go in place of the serviceRow line. The comment is load-bearing: the registry treats any mention of the service name in that file as "deliberately not served here", so the proto types / Connect client / frontend hooks keep generating for callers while the handler scaffold, wiring, MCP tools, and auth registration are gated off. (See generate_serve.go's package doc for the registry semantics.)
This addresses the orphan-stub hazard (FORGE_SHAPE_REDESIGN §7f): a service added but never implemented previously had no inverse command, so it lingered as an Unimplemented CRUD stub with no consumers. Delete is the explicit retirement path.
Destructive (it removes a directory), so it confirms before acting unless --yes; --dry-run prints the plan and changes nothing.
Package cli — bridge from declared platform deps (forge.HelmChart) to the cluster apply pipeline's helm-as-a-RENDERER path.
The KCL layer projects each forge.HelmChart into the `output.helm_charts` contract (HelmChartEntity). This file turns those declarations into internal/cluster.HelmChartSpec values — fetching the forge-supplied CRD bundle each chart needs (the pinned standard Gateway API CRDs for Envoy Gateway; cert-manager's CRDs at the chart version) so cluster.Apply can apply CRDs-first/Established-gated before the chart's --skip-crds controllers.
The CRD-fetch REUSES the existing pinned-CRD machinery (dev_cluster_ingress.go: ingressPinnedVersions + the cached fetch) that the k3d `forge cluster up` path already owns — keeping internal/cluster free of the templates/cache dependency (cli imports cluster, not the reverse).
Package cli — `forge cluster` command tree.
The `cluster` subtree consolidates the universal local-cluster lifecycle mechanics every k8s-targeting forge project would otherwise hand-write in bash, plus the dev-state introspection commands:
- up/down/reset/reload (k3d cluster lifecycle)
- status (dynamic: cluster up/down, pods, ingress URLs)
- urls (ingress URL table for the env)
- logs / info / instances (per-namespace introspection)
kubectl port-forward isn't wrapped here — the Gateway API ingress path (forge cluster urls) is the canonical "reach a service from the host" entry point. Ad-hoc port-forwards for stateful workloads (database shells, debug metrics endpoints) are `kubectl port-forward` directly.
Project-specific orchestration (sibling-repo deploys, helm bootstraps, Stripe webhook listeners, per-tenant seeds) is NOT owned by forge — projects keep those in scripts/ and Taskfile.yml, composed with the forge cluster primitives. See the `dev` skill for the boundary doc.
Package cli — `forge cluster` k3d lifecycle subcommands.
This file consolidates the k3d cluster lifecycle that every k8s-targeting forge project would otherwise hand-write in bash (~30-50 lines of idempotent create / delete / wait-for-rollout / context-pin logic).
k3d itself is the source of truth for cluster state — we shell out to `k3d cluster create/delete/list` rather than reinvent. The value forge adds is:
- read deploy/k3d.yaml as the canonical config (no hand-written --servers/--no-lb/--registry-create flags scattered across scripts)
- idempotent up/down semantics (`up` no-ops if the cluster exists, `down` no-ops if it doesn't)
- kubectl context pinning so `forge cluster reload` can't accidentally apply to staging or prod
- one-command reload that re-renders KCL + applies + waits for rollout (the inner loop during local dev)
Package cli — `forge cluster up` ingress install plumbing.
forge is OPINIONATED on ONE Gateway API controller everywhere: Envoy Gateway (controllerName gateway.envoyproxy.io/gatewayclass-controller, GatewayClass `eg`). The local k3d bring-up installs the SAME helm chart the cloud envs (staging/preprod/prod) run, so a Gateway that renders `gatewayClassName: eg` behaves identically on a laptop and in the cloud. Envoy Gateway serves HTTPRoute AND GRPCRoute natively, so one controller covers every forge route shape — no second controller for gRPC.
Three pieces, run in order after the k3d cluster is created and kubectl context pinned:
- Fetch + apply the upstream Gateway API standard-channel CRDs (version pinned via internal/templates/ingress/envoy/VERSION). Cached under ~/.cache/forge/ingress/ so subsequent cluster-up runs are offline-capable. The gateway-helm chart bundles the CRDs it needs, but we apply the pinned standard channel explicitly first (server-side) so the CRD surface matches the cloud install and so `kubectl wait` on Established gates the GatewayClass apply.
- `helm upgrade --install --skip-crds` the Envoy Gateway controller from the pinned gateway-helm chart version into envoy-gateway-system, the SAME release the cloud envs run. `--skip-crds` is required: the chart bundles an OLDER, experimental-channel copy of the Gateway API CRDs that the safe-upgrades ValidatingAdmissionPolicy (shipped by the standard CRDs applied in step 1) would DENY — see helmInstallEnvoyGateway. Envoy Gateway provisions a managed Envoy proxy per Gateway with listener sockets derived dynamically from Gateway.spec.listeners — no static per-listener entrypoint config to template (unlike the old Traefik install). The proxy's LoadBalancer Service ports follow the Gateway listeners; on k3d the bundled klipper servicelb binds those node ports and the k3d serverlb forwards the host ports mapped from deploy/k3d-ports.yaml.
- Apply the vendored `eg` GatewayClass (idempotent).
Idempotency comes from `helm upgrade --install` + `kubectl apply` semantics — re-running against a cluster that already has them noops. We block on CRD establishment between (1) and (3) so the GatewayClass apply doesn't race the CRD install.
Also: k3d config merging — `forge generate` writes `deploy/k3d-ports.yaml` derived from the dev env's KCL gateway listeners. At cluster-up time we read deploy/k3d.yaml + deploy/k3d-ports.yaml, merge their ports blocks in memory, and hand a temp file to `k3d cluster create`. Keeps deploy/k3d.yaml user-owned while keeping host ports in lockstep with the project's declared Gateway listeners.
Package cli — `forge cluster up` mkcert TLS provisioning.
Why this exists: in dev, cert-manager's only stand-alone option is a self-signed Issuer whose CA isn't in the host trust store, so every browser shows a warning and every curl needs `-k`. mkcert installs a CA into the OS trust store once (`mkcert -install`) and signs leaf certs the host already trusts. The cluster-side shape stays identical to prod (Gateway carries `tls:`, listener is HTTPS, Secret is referenced normally); only the cert origin differs.
This file walks the dev env's rendered KCL, finds Gateways whose tls.mode == "mkcert", and provisions a kubernetes.io/tls Secret per (hostname, secret_name) pair. Re-running just refreshes the Secret.
Package cli — `forge cluster info` command.
Diagnostic dump of the dev-loop config: which cluster, which context, which namespace, which service ports. Replaces the small bash recipe every project would otherwise hand-write to debug "why is my ingress URL hitting the wrong service?"
Package cli — `forge cluster instances` command.
Lists every forge-managed dev namespace on every reachable k3d cluster. Supports the multi-worktree workflow where each worktree runs in its own namespace and we need a host-wide view to find what's running.
Package cli — `forge cluster logs` command.
Streams kubectl logs for one or all services in the dev namespace. Replaces a small bash recipe (resolve namespace, look up the right label selector for a service deployment) that every project would otherwise hand-write.
Package cli — dev-mode forge/pkg replace handling.
Background: when forge is checked out alongside a project (sibling directories on disk), the project's go.mod commonly carries a
replace github.com/reliant-labs/forge/pkg => /absolute/host/path/forge/pkg
directive so the project can use forge/pkg subpackages that haven't been published yet. This works for `go build` on the host but breaks `docker build`, because the absolute host path isn't visible inside the build context.
The canonical fix in forge: detect such absolute-path replaces during `forge generate`, vendor the target into `<project>/.forge-pkg/`, and rewrite the replace to `./.forge-pkg`. The Dockerfile template emits a corresponding `COPY .forge-pkg/ ./.forge-pkg/` line whenever the vendored copy exists, so docker builds and host builds use the same replace target by construction.
This is a development affordance — production deploys want forge/pkg as a real go.mod requirement, not a vendored copy. The opt-in is implicit (presence of the host-absolute replace in go.mod) and can be disabled with `forge.yaml -> dev.vendor_local_forge_pkg: false`.
Release builds of forge skip this flow entirely: they scaffold a clean `require github.com/reliant-labs/forge/pkg vX.Y.Z` pin (no replace), and the sync below is contractually a no-op for such projects (TestSyncDevForgePkgReplace_CleanVersionPinUntouched). `forge doctor` warns when a project is still vendored even though the running forge release publishes a pkg version (doctor_pkgpin.go). Full model: docs/pkg-versioning.md.
Package cli — `forge cluster status` command.
Renders a human- or machine-readable snapshot of the dev cluster, running pods, and ingress URLs derived from rendered KCL. Replaces a 30-line bash recipe every k8s-targeting forge project would otherwise hand-write.
Package cli — `forge cluster urls` command.
Reads the rendered dev-env KCL and prints the ingress URL table — one row per HTTPRoute/GRPCRoute, grouped by gateway + listener. Lets users (and sub-agents) discover "what URL do I hit" without reading deploy/kcl/dev/ingress.k by hand.
Package cli — `forge devstack` command tree.
The parallel-dev-stack primitives (ADR 0003) live in internal/devstack: the raw git facts pushed into KCL as options, and the memoized forge.allocate_port(base, key) block allocator. Those primitives are resolved INSIDE a KCL render (under the up/deploy activation path). But a host launcher — a Taskfile target, a bootstrap script — needs the SAME allocated host port BEFORE `forge up` renders the KCL, so it can start the host `reliant` process LISTENING on exactly the port the in-cluster controller will dial.
`forge devstack port <base>` is that single source of truth: it resolves the current worktree key (devstack.Worktree) and returns allocate_port(base, key) — base + block(key)*100 — through the SAME lock-guarded block registry (.forge/blocks.json) the KCL builtin uses, so the launcher and the render can never disagree on the port. On the PRIMARY checkout the key is "" ⇒ block 0 ⇒ the base is returned unchanged (no registry/lock touch), so the default dev loop is byte-identical to today.
`forge disown <path>... --reason <text>` — one-way ownership transfer.
The file lifecycle has exactly two states and one door:
- forge-owned (Tier-1): regenerated on every `forge generate`; hand-edits are a hard drift error.
- user-owned ("yours"): never touched after emission.
- `forge disown` is the one-way door from the first to the second.
There is deliberately NO fork limbo (forked-but-maybe-reconciled- later). A fleet-wide audit of every long-lived fork found zero that were justified — each was a bug, a missing API, a mis-tier, or staleness — while the limbo state produced the worst incidents (silently frozen wire_gen.go). Disowning is final by design; the documented re-adoption path is equally simple: delete the file and run `forge generate` — the emitter re-emits the pristine render and the entry returns to Tier-1.
--reason is REQUIRED. A disown means the generated code couldn't express what the user needed — that's design feedback, and the reason is the payload. It is recorded per path into .forge/friction.jsonl (area=disown) through the same append-only machinery as `forge friction add`; `forge audit --json` joins it back onto each disowned_files row.
Package cli — `forge doctor` Docker daemon HTTP(S)-proxy check.
When the Docker daemon is configured with an HTTP/HTTPS proxy, ALL container egress — including image pulls and registry TLS handshakes — is routed through that proxy. A well-behaved forward proxy tunnels the registry's CONNECT request straight through and pulls work fine. But a TLS-intercepting proxy (Proxyman, Charles, mitmproxy, a corporate MITM box) terminates the CONNECT and re-originates TLS with its own cert. The container runtime, which has no trust path to that cert, sees the pull fail mid-stream — surfacing as ImagePullBackOff, "unexpected EOF", or "connection reset by peer" on the image layer. The proxy is invisible to the user (Docker Desktop silently inherits the macOS system proxy, commonly `http://http.docker.internal:3128`), so the failure is notoriously hard to trace back to its cause.
Doctor surfaces the proxy as a WARN, not a fail: a proxy can be perfectly legitimate (a transparent corporate egress proxy, a pull- through cache). We can't tell from `docker info` alone whether it intercepts TLS — only that one is present and that, IF it does, image pulls will break in a way that looks like everything except a proxy. The message names the symptom and the fix so a user staring at an ImagePullBackOff can connect the two.
The decision core (dockerProxyCheck) is pure — it takes the already- extracted proxy strings — so unit tests cover every branch without shelling out to docker.
Package cli — `forge doctor` external-builds checks (Phase 3 of the build_cmd refactor).
For every KCL service whose Service.BuildCmd is non-empty doctor emits three observations:
- warn when the resolved build_cwd (or projectDir when BuildCwd is unset) is missing on disk. Build-side semantics already skip-with-warn at run time; doctor surfaces the gap up-front so the user can fix it before invoking `forge build`.
- warn when the first token of BuildCmd (split on whitespace, take index 0) isn't on PATH. Heuristic — skipped when the command opens with `cd ` or contains a `KEY=value` env-var assignment, because those need a real parse pass that's out of scope for v1.
- info recording the substituted BuildCmd against a synthetic Spec, so the user can spot ${X} substitution errors before running build.
All three observations live under one doctor check per service (Name="external-build: <service>") so the report stays compact even for projects with many external-build services. The check status rolls up: any warn beats pass; the heuristic-skipped paths still emit the info line so the substituted command is always visible.
The decision core (buildExternalBuildDoctorChecks) is pure — it takes the resolved KCL service set + a PATH lookup function + a stat function. Production passes exec.LookPath / os.Stat; unit tests pass map-backed stubs. Mirrors doctor_tools.go's seam.
Package cli — `forge doctor` ingress checks (Phase 4 of the Gateway API ingress refactor).
Two new check categories layered on top of the standard doctor signal-based checks:
- GatewayClass present — for each unique gateway_class_name declared across every env's rendered KCL, verify a matching `GatewayClass` resource exists in the active cluster context.
- cert-manager ClusterIssuer present — for each Gateway with tls.cert_issuer set in any env's KCL, verify a `ClusterIssuer` resource with that name exists.
Both checks are best-effort observation: they degrade to "skipped" with a clear reason when kubectl isn't on PATH, the cluster isn't reachable, the CRD itself isn't installed, or KCL can't be evaluated. Doctor must never fail-loud on environmental gaps the user hasn't fixed yet.
The cross-check logic is factored into a pure helper (buildIngressDoctorChecks) that takes already-collected class / issuer names plus a kubectl-resource-checker interface, so unit tests can exercise every status outcome without a live cluster.
Package cli — `forge doctor parity <svc>` subcommand.
Detects when a service's effective env+config diverges between host-mode (`forge run <svc>`) and cluster-mode (`forge deploy <env>`) projection — the "local wasn't representative of prod" class of bug that surfaces as a deploy failure on Friday afternoon.
The check is static: we compute what each mode WOULD see by composing the same inputs the real loaders compose (forge.yaml environments[<env>].config + KCL host env_vars on the host side; KCL cluster env_vars on the cluster side), then diff. No process is spawned, no manifest applied, no Secret read — just a structured report of where the two modes disagree.
Three categories of divergence:
- value_mismatch — same key set on both sides with different inline values. Always a bug (exit 1).
- missing_in_<side> — key set in one mode, absent in the other. Bug (exit 1) UNLESS the cluster side projects from a Secret (host's secrets_file is the documented counterpart but we don't load it — see secret_channel_divergence below).
- secret_channel_divergence — same key is sourced from secrets_file on host and `secretRef` on cluster (or one side references a ${SECRET_REF} placeholder). EXPECTED — the channels differ by design. Reported so the model can verify the key names line up, but does NOT fail the exit code.
The diff core (diffParity) is pure: it operates on two side-input structs the cobra path assembles from real loaders and tests assemble in-memory. Filesystem touches stay in the cobra path.
Package cli — `forge doctor` forge/pkg dependency-mode check.
Generated projects can satisfy their github.com/reliant-labs/forge/pkg dependency two ways (docs/pkg-versioning.md):
- RELEASE flow: `require github.com/reliant-labs/forge/pkg vX.Y.Z` pinned against a published pkg/vX.Y.Z submodule tag, no replace.
- DEV flow: a replace directive pointing at ./.forge-pkg (a vendored copy synced from a forge checkout) or at a host-absolute checkout path.
The dev flow is a development affordance. Once the forge release a project tracks ships a published pkg version, staying on the vendored copy means the project silently stops receiving released pkg code and keeps a ~116-file copy committed in its tree. Doctor surfaces that "stuck on the dev path" state as a warning with the exact commands to switch over. The decision core (pkgPinCheck) is pure — it takes the go.mod contents and the published version string — so unit tests cover every branch without touching buildinfo or the filesystem.
Package cli — `forge doctor` host-tool checks.
Forge shells out to a handful of third-party binaries (kcl, kubectl, docker, go, buf, k3d, git, npm, mkcert, ...). When one of those is missing the user sees a cryptic `exec: "kcl": not found` from wherever the binary was first invoked — usually deep inside a multi-phase pipeline that has already partially run. Doctor's job is to surface the gap up front with an OS-specific install hint so the user can fix the toolchain before the failure cascades.
The check decision core (runToolChecks) is pure: it takes function values for "is binary on PATH" and "run version command" so unit tests cover every status branch without exec-ing real processes.
The set of tools required for a given project depends on which `features.*` blocks are enabled (deploy needs kcl + kubectl; frontend needs npm; ingress with mkcert TLS needs mkcert host-side; etc.). Each toolCheck carries a Required(cfg, projectDir) predicate so the report skips tools the project doesn't actually use rather than emitting "missing optional tool" noise.
Package cli — `forge friction` command.
Friction formalizes the downstream→upstream feedback loop: when an agent (or human) working in a forge project hits generator friction — a lint false-positive, a scaffold quirk, a missing helper — the observation is captured AT THE MOMENT OF FRICTION with one dumb, durable, local command:
forge friction add "wire_gen drops Deps fields added after scaffold" \ --severity p1 --area codegen --source fix-validate-agent \ --context pkg/app/wire_gen.go:42
Records land in `.forge/friction.jsonl` — append-only JSONL, one self-contained object per line. JSONL is deliberate:
- appends are atomic-ish (single O_APPEND write, no read-modify-write)
- merge-friendly (git unions lines; no rewrite conflicts)
- immune to the markdown-rewrite failure mode that lost ~65 findings in the field (an LLM batching prose appends got rate-limited mid-rewrite and dropped the batch)
No LLM, no network, no rewriting of existing lines, ever. Reading tolerates (skips + counts) malformed lines so a torn write can never brick the log.
`forge friction list` filters/renders the log, `forge friction export` renders FRICTION.md-style markdown to stdout (projects that want a checked-in FRICTION.md redirect it; forge does not own that file), and `forge audit --json` surfaces a summary under the additive `friction` category so standing friction is visible where agents already look.
Disown friction capture.
A disown is design feedback: when a user (or agent) runs `forge disown` (or the deprecated `forge generate --accept` alias) on a generated file, they are saying the generated API/abstraction couldn't express what they needed. That signal is worth exactly as much as it is durable — so we capture it AT THE MOMENT OF DISOWNING, through the same append-only .forge/friction.jsonl machinery `forge friction add` uses (one O_APPEND write per entry, never read-modify-write; see friction.go for the durability rationale).
Design constraints, in order:
- Never prompt. Agents drive these commands non-interactively; a blocking "why did you disown this?" prompt would hang every automated run. The commands require --reason up-front instead; the placeholder branch survives only as a backstop for callers that reach the recorder without one.
- Never block the disown. By the time we record, the checksum flip has already been persisted — failing the command over a friction- log write would leave the user with a succeeded disown and a failed exit code. Write failures warn loudly and continue.
- One entry per path. `forge friction list --area disown` and the audit disowned_files reason lookup both key on the per-path context, so a batched disown of N files must produce N independently-queryable records.
Marker-driven stale-artifact cleanup.
Forge's pre-2026-06-05 cleanup walked generated-artifact directories and removed anything whose name didn't match a re-derived "expected" set computed from forge.yaml — and deleted user code when the re-derivation disagreed with on-disk snake_case proto layouts. The manifest era fixed that by deleting only manifest-recorded paths; the self-certifying era keeps the same safety property without the manifest: only files that carry a forge:hash certification marker are candidates. The marker IS forge's authorship record, embedded in the file itself — paths without one are user content and forge doesn't get to delete them.
A candidate is a marker-bearing file the current run did NOT re-emit (per the WrittenThisRun set). Guardrails before deletion:
- Owner-step gate. The tier1OwnerRegistry in generate_tier1_scope.go maps generated paths back to the step that emits them. A path whose owning step is gated off this run is left in place; that step wasn't going to write it regardless, so missing-from- WrittenThisRun is uninformative.
- Upgrade-managed paths. `forge upgrade` is their emitter; `forge generate` deliberately leaves them alone.
- Verification. Only PRISTINE markers (certified machine output) are deleted. A file whose marker fails verification was hand-edited — stale or not, those bytes are the user's now, so it is reported but never removed.
- Disowned paths are never candidates.
Scoped-fallback entries (.forge/hashes.json, comment-incapable formats) get the same treatment keyed off the recorded hash.
Config vs. filesystem cross-check — the "loud-by-default" guard.
Pre-2026-06-07 the generate pipeline silently skipped declared entities that had no on-disk backing, and silently ignored on-disk entities that weren't declared in forge.yaml. The asymmetry was the #1 source of "I added a service but generate did nothing" friction:
- forge.yaml declares services[].name=foo but proto/services/foo/ doesn't exist → generate runs, emits nothing for foo, exits 0.
- proto/services/foo/ exists on disk but forge.yaml lacks an entry → generate sees the proto but skips bootstrap wiring for it.
- packages[].name=foo but internal/foo/contract.go missing → bootstrap codegen emits a broken import the validate step fails on, pointing at generated code instead of the missing contract.
This file walks forge.yaml's declarations and the proto/internal trees in parallel and collects every mismatch into a single batched report. stepLoadConfig calls validateConfigVsFilesystem after a successful LoadStrict so the user sees the asymmetry the moment they run generate, not three steps deeper at a confusing "missing import" error.
Opt-out: --skip-config-check. We expose an opt-out (not opt-in) so the default path is loud — new adopters get the check unconditionally, and scripted callers in pathological setups (e.g. mid-migration where a proto dir exists transiently with no forge.yaml entry yet) can pass the flag to bypass without changing the steady-state default.
Disowned-sibling dangling-reference detection.
FRICTION 2026-06-04 (cp-forge layer-6 workers lane, fork era): the user had frozen `pkg/app/bootstrap.go` and `pkg/app/wire_gen.go` out of regeneration. After adding a worker, `forge generate` happily re-emitted `pkg/app/app_gen.go` — and the freshly regenerated `app_gen.go` declared `Workers *Workers`. But the `Workers` TYPE was defined inside the frozen `bootstrap.go`, still at its pre-workers content. `go build` then failed with `pkg/app/app_gen.go:42:13: undefined: Workers` — a silent build break far from its cause.
The same hazard exists for DISOWNED files (the frozen-file mechanics are identical: forge never re-emits them, even with `--force`), so the check survives the fork removal with disowned entries as its subject. Resolution: after codegen finishes, scan the regenerated forge-owned Tier-1 files inside the same package as any disowned sibling. For each referenced package-local type name that is NOT defined in either
- the file doing the reference, or
- any forge-owned sibling in the same package, or
- any disowned sibling in the same package,
surface a loud, actionable error listing the type, the call site, the disowned sibling that probably ought to have defined it, and the concrete escape hatches (hand-add the missing declaration to the disowned file — it's yours — or re-adopt it by deleting it and re-running `forge generate`).
The scan is restricted to the same Go package as the disowned file because that's where Go's package-local resolution applies — a dangling unqualified type name `Workers` in `app_gen.go` can only be resolved by a sibling .go file with the same `package app` clause. Cross-package references (e.g. `foo.Workers`) are qualified and would be flagged by `go build` with a different error class; they're out of scope here.
Per-file extension-point hints for Tier-1 drift.
When the stomp guard catches a hand-edited Tier-1 file, the worst thing the error can do is lead with `forge disown`: agents take the path of least resistance, disown the file, and permanently lose regeneration (the failure chain this whole subsystem exists to prevent). The right answer is almost always "your customization has a designated user-owned home" — these hints name that home, per file shape, so the error message teaches the extension point first and the disown one-way door last.
Package cli — `forge generate --explain` provenance log.
Explain mode runs the normal generate pipeline and then prints a per-file traceability log: for each forge-tracked output, which source files / proto descriptors / contract.go inputs drove its generation, plus a "reason" field saying whether the file was rewritten or skipped because the input was unchanged.
Implementation choice: rather than instrument every individual generator (mock_gen, middleware_gen, crud_gen, …) with a callback, we read the post-generate state from forge_descriptor.json, the project filesystem, and the embedded forge:hash markers. The derived provenance is "approximate but useful" — exactly what the LLM caller wants. We can tighten it later by threading a callback through the codegen package, but we don't need to pay that cost up front to ship the most-useful 80%.
`forge generate --explain-drift` — show what regeneration would change before the user picks an escape hatch.
The Tier-1 stomp guard knows a file drifted (hash mismatch) but only has hashes for prior renders, not content — so it can't show the user WHAT changed. The actionable comparison is on-disk content vs a fresh render of the *current* templates: that diff is simultaneously "what did I hand-edit" and "what would --force destroy".
Mechanism: instead of aborting at the guard, --explain-drift marks every drifted path side-render-only (checksums.AddSideRenderOnly) and lets the pipeline proceed. The emitters render normally, but writes for drifted paths land at .forge/render/<path>; the user's on-disk content and the checksum entries stay untouched (the entries are snapshot-restored at the end because stepRehashTracked would otherwise re-stamp the drifted on-disk hash and silently bless the drift). After the step loop, each drifted file is diffed against its parked render via `git diff --no-index`, bounded, and the run still FAILS with the standard drift report — the flag explains, it does not approve.
Kept out of generate_pipeline.go (a parallel-edit hotspot): the pipeline file gains only the ctx fields and a three-line branch in stepCheckTier1Drift.
One-time pipeline migration off the legacy .forge/checksums.json global manifest onto self-certifying files (forge:hash markers).
The conversion rules live in internal/checksums/migrate.go. This file owns the pipeline integration:
- stepMigrateLegacyManifest runs between state load and the Tier-1 stomp guard. Pristine legacy entries get stamped; disowned ones convert to .forge/disowned.json; entries whose bytes match NOTHING the manifest recorded (kalshi fr-9a54388f0b: a manifest committed from a different work lane) are quarantined on ctx.LegacyUnverified with their writes side-render redirected.
- finishLegacyMigration runs after the step loop: each quarantined path whose fresh side render matches the on-disk bytes is proven pristine and stamped; everything else is stamped with the unverified-legacy sentinel and reported through the standard drift error (guard semantics: --force regenerates exactly the named files, `forge disown` keeps them).
The legacy manifest is deleted by the migration itself — durable even when the run later aborts. The unverified sentinel keeps the guard honest across runs until the user resolves each file.
Package cli — typed step plan for `forge generate`.
Pre-2026-05-06, runGeneratePipeline was a 584-line procedural function holding 25 numbered ordered steps gated by 93 Features.*Enabled() checks. New steps were 30 lines of boilerplate appended to a numbered-comment sequence (Step 0a, 0b, 0b.1, ..., 8d-iii, 8f.1) — the numbering itself pleaded for a data structure (FORGE_REVIEW_CODEBASE.md Tier 1.1).
This file replaces that procedural blob with a typed []GenStep plan. Each step is a small named function operating on a shared pipelineContext. The pipeline becomes a loop over the slice that:
- calls step.Gate(ctx) — pure, side-effect-free predicate; false skips
- calls step.Run(ctx) — the action, returning an error to abort
The shape unblocks several downstream wins documented in the codebase review:
- --plan / --explain print the plan without executing it
- `forge dev` watch loops re-run only steps whose Tag matches changes
- per-step unit tests against a synthetic pipelineContext
- one-time parse of services/entities into ctx (avoids re-parsing ParseEntityProtos 3× — see Tier 2.5)
As of 2026-05-07 the entire pre-refactor pipeline is now flat: every numbered legacy step has a dedicated stepXxx entry below, and runMidPipelineLegacy is gone. The single remaining shared-state hop (parse services + module path once for steps 4-6) is its own GenStep.
(2026-05-06 polish-phase, completed 2026-05-07) — closes FORGE_REVIEW_CODEBASE.md Tier 1.1.
`forge generate --plan` and the loud-by-default helpers.
The plan mode complements --explain (provenance log after the run) and --check (drift detector that runs the pipeline) by inspecting the pipeline WITHOUT running anything: it prints the step list with a [RUN] / [SKIP] annotation per step, derived from each step's Gate(ctx) against the configured project state, then exits.
This is the diagnostic that answers "what would forge generate do here?" without the side-effects of actually doing it — useful when planning a big refactor, debugging "why didn't generate touch X?", and writing CI guards that need to know whether a pipeline change would alter the step set for a given project shape.
The warnOrFail helper lives here too because it shares the loud-by- default thesis: --strict promotes the historically-silent "Warning: ... failed" sites to hard errors via a single helper that every per-step body can opt into without per-site code changes beyond "use this helper instead of fmt.Fprintf + return nil".
Rename detection for Tier-1 Go emitters.
FRICTION 2026-06-02 (cp-forge dogfood pass): when `forge generate` renames a public symbol in a Tier-1 Go file (e.g. `db/embed.go` flipping `var Migrations` to `var MigrationsFS`), hand-written callers elsewhere in the project keep referencing the old name and the build breaks two runs later — long after the codegen step that did the rename has scrolled out of the user's terminal.
Resolution: a two-step pre/post pass around the codegen body of the pipeline. Pre-codegen we snapshot the public exports of every tracked Tier-1 Go file; post-codegen we re-extract exports from the freshly written files and diff. Names that disappear are candidates for stale references — we grep the project (skipping generated files and the renamed file itself) for `pkg.Name` and `Name` patterns and surface each call site as a warning.
The warnings are advisory by default. Future work: --strict-renames converts them to a hard pipeline error.
Auto-retire obsolete disowns.
`forge disown` is a last-resort one-way escape hatch: it tells forge "stop regenerating this Tier-1 file, the bytes are mine now". Needing it routinely means a missing extension point. A disown that has become OBSOLETE — forge no longer Tier-1-owns that path — is dead weight: it protects against an overwrite that can no longer happen, and it misleads users into thinking disown is normal.
The canonical case: a project disowned a frontend page.tsx under an OLD forge that regenerated page.tsx as Tier-1. The current forge makes page.tsx Tier-2 (scaffold-once: written if absent, NEVER overwritten). The disown is now meaningless — forge would never overwrite a Tier-2 file anyway — yet it lingered across version migrations and had to be hand-dropped.
THE FIX: during `forge generate`, after every Tier-1 emitter has run, detect disowns whose path is NO LONGER a current Tier-1 emit target and auto-retire them (remove from .forge/disowned.json) with a loud per-path notice. A disown is:
- STILL VALID when its path IS a current Tier-1 emit target — forge WOULD regenerate it but for the disown. The target set (checksums.Tier1TargetSet) records every path a Tier-1 writer touched this run, BEFORE the disown-skip, so a disowned-but-live Tier-1 path is still in the set. Leave it alone.
- OBSOLETE when its path is NOT a current Tier-1 emit target — it became a Tier-2 scaffold-once file, or forge stopped emitting it. Retire it.
Conservatism: a disowned path can be absent from the target set for a reason unrelated to tiering — the emitter that WOULD own it was gated OFF this run (e.g. a frontend file under features.frontend=false). In that case absence is uninformative and we must NOT retire. The `targetable` predicate below gates retirement on "an emitter that could own this path as Tier-1 actually ran this run", so a gated-off subsystem never sheds its legitimate disowns.
What a binary serves is CODE, not config.
The serving decision lives in the user-owned pkg/app/services.go: RegisteredServices lists one generated serviceRow<X> call per service this binary serves. forge scaffolds that file once (listing every current service — zero semantic change for existing projects) and never rewrites it; from then on the row list is the single source of truth, and this file derives the served-set from it by AST-parsing the registration file (the same user-file-parsing posture as codegen.ParseServiceDeps and the setup.go scans).
Classification per service (see serviceRegistry.state):
REGISTERED — a serviceRow<X> identifier matching the service is referenced in services.go. Full treatment: handlers scaffold, CRUD/authorizer/mocks, wire/diagnostics, MCP manifest tools, auth-middleware skip-list, bootstrap row (via the user's list). UNLISTED — the service name appears NOWHERE in services.go. Treated as newly added (forge add service / hand-edited forge.yaml): the handlers scaffold and row constructor still generate so the user can implement-then-register, but the service is NOT served — no MCP tools, no auth skip-list entries, `forge run` skips it, and `forge audit` warns that the row constructor is unreferenced. The registration line is written by the USER (or their agent) — forge prints it but never edits the file. That's the design: the LLM writes the one decision line; forge generates the guardrails. TOMBSTONED — the service name appears in services.go only in a comment (the scaffold instructs: delete the row, leave a comment naming the binary that serves it). Types-only: proto types, Connect client, frontend hooks, and descriptor entries still generate (callers need them); the handlers/<svc>/ scaffold, row constructor, wire/diagnostics, MCP tools, and auth registration are gated off. The comment is load-bearing — without any mention the service reverts to UNLISTED and its scaffold regenerates.
Fallbacks: a missing services.go means "everything registered" (pre-migration projects keep today's behavior; the generate pipeline then scaffolds the file with exactly that meaning). A services.go that does not PARSE is a hard error in the generate pipeline (the build would fail anyway; better to name the file) and fail-open (everything registered) for read-only commands like audit/graph, which must not die on a broken tree.
Retirement of a pre-existing handlers/<svc>/ scaffold reuses the manifest-driven stale sweep (generate_cleanup.go): the gated emitters stop writing the tracked Tier-1 files, so they fall out of WrittenThisRun and become report-only removal candidates (deleted under --force-cleanup); Tier-2 user-owned files in the same dir are never candidates. `forge audit` surfaces both halves via the codegen category's unregistered_services finding.
`forge generate` step: version-true delivery of forge-shipped skills.
Skills used to reach generated projects only as a scaffold-time copy (or a manual `forge skill write`), after which the on-disk SKILL.md files froze at scaffold-date and drifted from the project's pinned forge version. This step makes `.claude/skills/` a Tier-1 codegen output: every `forge generate` re-renders the forge-namespace skills from the embedded templates of the RUNNING forge binary, so the disk copies always match the binary that last generated the project.
Scope is strictly the forge-shipped skill set — the step only ever writes the exact `.claude/skills/<flat>/SKILL.md` paths derived from the embedded skill list. User-created skill directories under `.claude/skills/` are never touched (and never deleted).
Tier transition: projects that predate this step have skill files that carry no forge:hash certification marker — they froze as Tier-2/legacy scaffold output. Those entries migrate to Tier-1 on the first run: a differing on-disk copy is a stale scaffold-era render, NOT a precious user edit, so it is overwritten with a one-line notice per file. Once an entry is Tier-1, the standard pre-pipeline stomp guard (stepCheckTier1Drift) applies — hand-edits surface as drift and require --force / `forge disown` like any other Tier-1 file.
Stale scaffold-test detection.
FRICTION (front-door P0, 2026-06): the scaffold proto explicitly instructs renaming Item to the real entity. Following that instruction leaves handlers/<svc>/handlers_scaffold_test.go — a ONE-SHOT, user-owned file rendered from internal/templates/service/unit_test.go.tmpl, never regenerated, no checksum entry — referencing deleted pb types (pb.CreateItemRequest, …). `forge generate` succeeds because the final validate step is `go build`, which does not compile _test.go files; the user then hits an immediate `go test` / `go vet` failure pointing at a file forge wrote, with no hint that the file is theirs to fix.
Resolution: NO auto-regen of user-owned files. After codegen has refreshed gen/, scan each handlers/<svc>/handlers_scaffold_test.go (and only that filename — keep scope tight), resolve its `pb "<module>/gen/…"` import to the on-disk generated package, and cross-check every `pb.<Ident>` reference against the names that package actually declares. Any referenced-but-undeclared ident → one one-line warning naming the file and telling the user the file is theirs: delete it or update the rows. Default is a warning (generate still succeeds); --strict promotes it via the standard ctx.warnOrFail helper.
Tier-1 stomp-guard scoping.
The pre-pipeline stomp guard (stepCheckTier1Drift) used to fail on ANY drifted Tier-1 file the scan found, even when the current `forge generate` invocation would never re-emit that file. In multi-lane migrations that hard-failed the guard for sibling work (e.g. agent A is porting internal/proxy/ and the guard rejected because agent B left pkg/app/migrate.go drifted in a separate changeset). FRICTION 2026-06-02: cp-forge dogfood pass.
Resolution: the guard now filters drift to the set of paths whose owning emitter step would actually run for this pipelineContext. A drifted file emitted by a step whose Gate returns false this run is silently ignored — that step wouldn't touch the file, so its drift cannot manifest as a stomp.
The path → owning-gate registry is intentionally explicit (small map at the bottom of this file). Adding a new Tier-1 emitter SHOULD add an entry here so the scoping logic stays accurate; the registry is fail-open (an unmapped path falls through to "in scope" so missing entries err on the safe side of preserving the loud-fail behavior).
The registry is keyed by exact path, trailing-slash prefix, or suffix-glob (path/segment pattern with a leading '*' wildcard). The matcher walks the entries in declaration order and returns the first match, so order from most-specific to least-specific.
Tier-2 (user-owned-after-scaffold) exemption for the drift scan.
Some forge-certified files are deliberately NOT stomp-guarded even though they carry a forge:hash marker: the upgrade-managed "checksum-protected" starters (Dockerfile, Taskfile.yml, .golangci.yml, …) and the one-shot .github scaffolds. The marker's job there is to let `forge upgrade` distinguish "still the pristine scaffold" (auto-update on version bumps) from "user customized it" (skip) — editing them is SANCTIONED, so a failed verification must not abort `forge generate`.
generator.Tier2ManagedPaths is the registry of those paths. This file hosts the cached set plus the filter the drift consumers (stomp guard, audit, ci verify-generated) apply to the raw ScanTier1Drift result.
(Historical note: this file used to flip stale tier=1 manifest entries to tier=2. The manifest is gone — the reclassification story is now: the legacy-manifest migration never stamps Tier-2-managed paths, the Tier-2 writer un-stamps reclassified pipeline outputs on their next scaffold pass, and this filter exempts the rest.)
Package cli — `forge introspect` command group.
Introspect surfaces "what the binary will actually register" without requiring the binary to be running. The current leaf is `handlers`, which walks the project's proto services and prints every RPC path in the canonical Connect form `/<package>.<service>/<method>`.
Use case: you wired a new service and want to confirm at a glance that every RPC will be reachable at the expected URL — catches "this service isn't wired" issues in seconds rather than via curl-debugging a running server.
Package cli — cross-cluster kubeconfig minting (forge.KubeconfigSecret).
In a multi-k3d-cluster dev/e2e env, a workload in cluster A may need to talk to cluster B's API server. A kubeconfig with B's serverlb IP baked in goes stale every time B is recreated (k3d hands the container a fresh docker-network IP). This file mints that kubeconfig FRESH each `forge up` — resolving the endpoint at mint time, never persisting the IP — and stores it as a k8s Secret the workload mounts.
The "in-network" reachability seam is the dev/e2e path: the target's API server is only reachable by its serverlb container IP on the shared docker network, and that IP isn't in the serverlb cert SANs, so the minted kubeconfig points at https://<ip>:6443 with TLS verification disabled. "endpoint" (prod) uses the kubeconfig's own endpoint verbatim — a stable reachable address with a valid cert — and needs none of the insecure rewrite.
Package cli — `forge map` command.
Map is a tree-shaped view of the project with ownership annotations on every leaf. The annotations come from cross-referencing:
- The embedded forge:hash certification marker (verifies = forge-space pristine; fails = forge-space drifted), plus the scoped .forge/hashes.json fallback for comment-incapable formats.
- File-name conventions (*_gen.go = forge-regenerated).
- Forge banner check (`Code generated by forge` first line).
- FORGE_SCAFFOLD marker presence (scaffolded but not finalized).
- Health flags: hand-edits to forge-space files (drift), proto/db entities not represented in migrations, etc.
Filtering and depth control mirror the spec: --depth N, --filter <subtree>, --json. The non-JSON output is plain-text indented tree (no box-drawing chars in the source — we render them at print time so the file stays grep-friendly).
Package cli — shared helpers for `forge map` (and formerly `forge audit`, which moved to the internal/cli/audit group). These walk the project tree to classify migrations and forge-generated files. They stay in package cli because map.go consumes them directly; the audit group keeps its own copy of isForgeGeneratedBanner (it's a five-line stdlib wrapper, duplicated per cmdutil's policy rather than shared through a leaf package).
Package cli — `forge mcp` command surface.
`forge mcp serve` hosts gen/mcp/manifest.json as a live Model Context Protocol stdio server: every Connect RPC in the project becomes an MCP tool an agent can call. The manifest is a generated artifact (one tool per RPC) that, before this command existed, had no host — `forge mcp serve` is that host.
Transport: tools/call dispatches to the running Connect service over plain HTTP+JSON — exactly the shape `forge api curl` prints. The service port is resolved the same way `forge api curl` resolves it (forge.yaml services[].port, with the same fallbacks), so `--addr` only needs to be supplied when the default host/port don't match the running server (e.g. behind an ingress).
Auth: a forge dev server that is NOT in AUTH_DEV_MODE runs the auth interceptor, so a tokenless tools/call returns a clean Connect "unauthenticated" error (which itself proves the wiring). Pass a bearer token via --token or $FORGE_MCP_TOKEN to authenticate real calls. The same env var is the shared dev-token seam `forge api curl` can adopt for its own --token in future.
Package cli — the shared reconcile spine under `forge up` and `forge deploy`.
Both commands bring an environment toward its declared end-state; they differ on exactly two axes, made explicit here so the up-vs-deploy relationship is legible instead of buried in a blank-options call seam:
scope — WHICH entity kinds a run acts on:
* cluster — kubectl-apply the in-cluster workloads
(Deployments / Jobs / operators), the
compose deploy targets, and (for deploy)
the External dispatch.
* composeInfra — pre-warm the docker-compose infra
(postgres / nats / temporal) concurrent
with the build phase. up-only; deploy's
cluster apply already drives compose
targets through the same provider.
* host — start every `deploy: "host"` service as a
host process (go-run / air / binary /
delve). up-only.
* frontend — start every declared frontend's dev
server (`npm run dev`). up-only. (deploy
instead publishes Firebase frontends — a
fixed, structural step of the deploy
pipeline, not a scope field.)
lifecycle — what a run does once everything is started:
* once — reconcile and RETURN. `forge deploy` is
always `once`; so is the non-TTY `forge up`
(start host/frontend processes, persist
their PIDs, print the summary, return —
stop later via `forge up stop`).
* supervise — start the long-lived host/frontend
processes, HOLD on a signal channel, and
cascade-teardown on Ctrl-C. The interactive
`forge up` lifecycle.
* auto — defer the once-vs-supervise choice to a
TTY check at runtime (resolveUpLifecycle).
`forge up`'s default when neither --watch
nor --background is given.
In this vocabulary:
forge deploy <env> = scope{cluster} (+ structural Firebase publish),
lifecycle=once, opts=<from flags>
forge up --env=<e> = scope=all, lifecycle=auto,
opts={}
The surgical knobs (tag / rollback / prune / dry-run / context override / targets / skip-frontend) live on deployOptions — the cluster reconcile's option surface, shared by both commands. `up`'s cluster step passes a scope-derived (today: zero-value) deployOptions through the SAME named entry point deploy uses (reconcileCluster), so there is no longer a blank-`deployOptions{}` literal standing in for "deploy with no options."
Package cli — `forge up --env=<env>` orchestrator.
One command brings the whole loop up:
- Render the env's KCL → typed entity set.
- Build phase: docker build (per-platform) and push every cluster service / operator / cronjob image; go build each declared build-only variant binary.
- Deploy phase: kubectl apply the cluster manifests + wait operator rollouts + wait one-shot Jobs.
- Host phase: start every host-mode service as a host process, dispatching on deploy.Host.Runner (go-run / air / binary / delve).
- Frontend phase: start every declared frontend in its path dir.
- Wait Ctrl-C → cascade cleanup → exit.
Reaching cluster services from the host is the Gateway API ingress path (see `forge cluster urls`); ad-hoc shells against stateful workloads stay available via `kubectl port-forward` directly.
Replaces the dev-loop bash script every forge project would otherwise hand-write to coordinate build + deploy + run.
Index ¶
- Constants
- Variables
- func EnvExists(projectDir, env string) (bool, error)
- func Execute() error
- func ListEnvs(projectDir string) ([]string, error)
- func ListEnvsFromKCLDir(kclDir string) ([]string, error)
- func MergeDescriptorFragments(descriptorOut string) error
- func Name() string
- func NewRootCmd() *cobra.Command
- func RenderProjectMemory(projectRoot string) ([]byte, error)
- func RenderSkillForAudience(body []byte, audience SkillAudience) []byte
- func SetVersion(v, date, commit string)
- func WriteBuildState(projectDir, env string, state BuildState) error
- func WriteEnvReleases(projectDir string, er EnvReleases) error
- func WriteRelease(projectDir string, r Release) error
- func WriteSkills(outDir string, style SkillWriteStyle, audience SkillAudience) (int, error)
- func WriteSkillsWithOptions(outDir string, style SkillWriteStyle, audience SkillAudience, ...) (int, error)
- type BuildConfigEntity
- type BuildOnlyDeploy
- type BuildState
- type BuildVariant
- type ClusterEntity
- type CodemodFn
- type CodemodReport
- type ComposeDeploy
- type CronJobEntity
- type DNSRecordEntity
- type DeployConfigEntity
- type DockerBuild
- type EnvBinding
- type EnvReleases
- type ExplainEntry
- type ExternalDeploy
- type ExternalSecretEntity
- type FirebaseBundleDir
- type FirebaseHostingDeploy
- type ForgeDescriptor
- type FrictionEntry
- type FrontendDeployEntity
- type FrontendEntity
- type GRPCRouteEntity
- type GatewayAddressEntity
- type GatewayEntity
- type GatewayListenerEntity
- type GatewayTLSEntity
- type GenStep
- type GoBuild
- type HTTPRouteEntity
- type HandlerPath
- type HelmChartEntity
- type HostDeploy
- type IngressURL
- type K8sCluster
- type KCLEntities
- type KCLEnvVar
- type KubeconfigSecretEntity
- type ManualItem
- type MapNode
- type OperatorEntity
- type RBACSpec
- type Release
- type ReleaseArtifact
- type ReleaseGit
- type RenderedSecretEntity
- type RenderedSecretKeyEntity
- type SecretProviderEntity
- type ServiceEntity
- type ShellBuild
- type SkillAudience
- type SkillEmit
- type SkillListOptions
- type SkillMetaPublic
- type SkillRelevance
- type SkillScope
- type SkillWriteStyle
Constants ¶
const ( OwnershipUser = "user-owned" OwnershipForgeSpace = "forge-space, regenerated" OwnershipForgeDrifted = "forge-space, hand-edited (drift from regen)" OwnershipScaffold = "scaffold, FORGE_SCAFFOLD markers present" OwnershipScaffoldOnce = "user-owned, scaffolded once" OwnershipUnknown = "" )
Ownership classes used in the output. We pick a narrow set so a downstream agent can switch on them.
Variables ¶
var ErrProjectConfigNotFound = cmdutil.ErrProjectConfigNotFound
ErrProjectConfigNotFound is returned when forge.yaml does not exist. The canonical sentinel lives in cmdutil (the shared leaf package) so the dir-nested command groups compare against the same value; this is an alias.
Functions ¶
func EnvExists ¶
EnvExists reports whether `deploy/kcl/<env>/main.k` exists. Returns false (with no error) for the absent-env / missing-kcl-dir cases — callers typically convert that into a friendly "env not configured" error themselves.
func Execute ¶
func Execute() error
Execute is the entrypoint used by main() to dispatch the assembled root cobra command. Wraps NewRootCmd().Execute().
func ListEnvs ¶
ListEnvs returns the names of every environment declared via a `deploy/kcl/<env>/main.k` file. The list is sorted alphabetically for deterministic output. An absent `deploy/kcl/` directory yields an empty list (and no error) — that's the shape of a brand-new project, not a problem.
projectDir is the project root (the directory containing forge.yaml). Callers can pass either projectDir or kclDir directly — see ListEnvsFromKCLDir for the lower-level variant.
func ListEnvsFromKCLDir ¶
ListEnvsFromKCLDir is the lower-level discovery walker. It exists so callers that already have the kcl root (e.g. a forge.yaml- configured cfg.K8s.KCLDir) can skip the projectDir join.
func MergeDescriptorFragments ¶
MergeDescriptorFragments combines all per-invocation fragments under <descriptorOut>/<descriptorStageDir>/ into a single forge_descriptor.json at <descriptorOut>/forge_descriptor.json, then removes the staging dir. Called by runDescriptorGenerate in the parent process after buf returns.
Idempotent: running it twice is safe (the second run sees an empty stage dir and is a no-op apart from rewriting the final descriptor with what's already there). Returns nil silently when no fragments exist (clean projects with no services/entities/configs).
func Name ¶
func Name() string
Name returns the command name users should type to invoke Forge. When the binary is "forge" (standalone install), it returns "forge". When embedded in another binary (e.g. "reliant"), it returns "reliant forge". Forwards to cmdutil.Name so the dir-nested command groups share one implementation without importing internal/cli.
func NewRootCmd ¶
NewRootCmd builds and returns the fully assembled root command.
func RenderProjectMemory ¶
RenderProjectMemory reads forge.yaml at projectRoot, renders the project memory template with the project name + invoking CLI name, and returns the bytes. Used by out-of-process consumers (the reliant CLI) to inject framework context in-memory rather than reading a possibly-stale on-disk reliant.md.
The template body is the same one `forge new` writes for non-reliant harnesses, so in-memory and on-disk renderings stay byte-identical.
Returns an error when forge.yaml is missing, unreadable, has no `name:` field, or when the template fails to render.
func RenderSkillForAudience ¶
func RenderSkillForAudience(body []byte, audience SkillAudience) []byte
RenderSkillForAudience returns the skill body filtered for the given audience. For SkillAudienceGeneral, `<!-- @forge-only:start -->` ... `<!-- @forge-only:end -->` blocks are removed (markers included). For every other audience the body is returned unchanged.
Exported so out-of-process consumers (e.g. reliant) can apply the same filtering rule when serving a skill loaded via [LoadSkill]. Forge-side callers should generally use WriteSkills, which calls this internally.
func SetVersion ¶
func SetVersion(v, date, commit string)
SetVersion stamps the version/date/commit metadata used by the `version` subcommand and the rendered Cobra Version string. Called once from main() with ldflags-injected values.
func WriteBuildState ¶
func WriteBuildState(projectDir, env string, state BuildState) error
WriteBuildState persists a successful `forge build --push` to disk. Called by runBuild after every per-image push succeeds, so the most recent push is always the source of truth a subsequent `forge deploy <env>` consumes.
The directory is created lazily — projects that never use --push never grow a .forge/state/ tree. File is 0o644 (world-readable) to match the other .forge state files' mode; nothing in here is secret.
func WriteEnvReleases ¶
func WriteEnvReleases(projectDir string, er EnvReleases) error
WriteEnvReleases persists the binding ledger.
func WriteRelease ¶
WriteRelease persists a Release ledger. The directory is created lazily.
func WriteSkills ¶
func WriteSkills(outDir string, style SkillWriteStyle, audience SkillAudience) (int, error)
WriteSkills exports forge-shipped skills into outDir using the requested style, filtered by audience. SkillAudienceAll ("") writes every skill with the raw body — that's the canonical catalog export used by `forge skill write` from inside the forge repo. SkillAudienceGeneral drops emit:forge skills and strips @forge-only blocks from emit:both skills' bodies. SkillAudienceForge keeps everything for emit:forge|both skills and drops emit:general entries. Returns the number of skills actually written.
Exported so out-of-process callers (the reliant CLI embedding forge, the harness emission in `forge new`) can reuse it.
One-time migration skills (relevance: migration) are excluded — they document version transitions, not steady-state conventions, and bulk exports are how skill catalogs reach projects. Use WriteSkillsWithOptions with IncludeMigrations to export them too.
func WriteSkillsWithOptions ¶
func WriteSkillsWithOptions(outDir string, style SkillWriteStyle, audience SkillAudience, opts SkillListOptions) (int, error)
WriteSkillsWithOptions is WriteSkills with explicit listing options. Additive surface — WriteSkills' signature is frozen for embedders.
Types ¶
type BuildConfigEntity ¶
type BuildConfigEntity struct {
Type string // "go" | "docker" | "shell" | "" (absent)
Go *GoBuild // populated when Type=="go"
Docker *DockerBuild // populated when Type=="docker"
Shell *ShellBuild // populated when Type=="shell"
}
BuildConfigEntity is the dispatched-by-type view of a service's build block — the build-side analogue of DeployConfigEntity. The raw JSON is a tagged union; Type carries the tag; exactly one of Go/Docker/Shell is non-nil after [dispatchServiceBuild] runs. Type=="" means the KCL `build` block was absent (null) — callers fall back to the synthesized GoBuild default.
type BuildOnlyDeploy ¶
type BuildOnlyDeploy struct {
BuildVariants []BuildVariant `json:"build_variants,omitempty"`
}
BuildOnlyDeploy is the deploy block for services that produce binaries but never get a Deployment — sidecars, CLI builds shipped in a release artifact, etc. BuildVariants lets one service emit multiple binaries (different ldflags / build tags).
type BuildState ¶
type BuildState struct {
Image string `json:"image"`
Tag string `json:"tag"`
Registry string `json:"registry"`
// Pushed is true when the image was pushed to Registry. False for
// local/scp/compose builds — the image lives only on the build host.
// Recording the handoff no longer depends on a push (that gate left
// non-registry deploys with no tag to read); push just adds the
// registry coordinates.
Pushed bool `json:"pushed"`
// Git provenance of the build, so `forge deploy` can warn when it's
// about to ship a non-reproducible (dirty / untagged) build. Commit
// is the full HEAD sha; GitTag is the exact tag on HEAD (empty when
// HEAD isn't tagged); Dirty is true when the working tree had
// uncommitted changes at build time.
Commit string `json:"commit,omitempty"`
GitTag string `json:"git_tag,omitempty"`
Dirty bool `json:"dirty,omitempty"`
// PushedAt is the wall-clock build time, formatted as time.RFC3339.
// The state file is informational across forge invocations, so we use
// real time here — reproducibility constraints don't apply.
PushedAt string `json:"pushed_at"`
// Digest is the content-addressed manifest digest of the pushed image,
// in the canonical `sha256:...` form (no `@` prefix, no repo). Captured
// from the registry after `docker push` succeeds (see imageRepoDigest).
// EMPTY for non-pushed builds (local/scp/compose — the image lives only
// on the build host with no registry manifest to address) and for any
// build where the digest lookup failed (capture is best-effort and never
// fails the build).
//
// When present, `forge deploy` pins the manifest to `<image>@<Digest>`
// instead of the mutable `:Tag` — a digest can't go stale and can't be
// re-pointed, so the node-cache / re-tag-didn't-take failure class is
// structurally impossible. When empty, deploy falls back to the tag, so
// every non-pushed transport keeps working unchanged.
Digest string `json:"digest,omitempty"`
// Platforms is the set of OS/arch platforms the pushed image advertises
// (e.g. ["linux/amd64"], or both for a multi-arch index), captured from
// the registry manifest alongside Digest. Informational today (a human
// can eyeball what arch shipped); the deploy preflight inspects the live
// image's arch independently. Empty when the lookup failed or the build
// wasn't pushed.
Platforms []string `json:"platforms,omitempty"`
}
BuildState records what `forge build --push` actually pushed to a registry, so a subsequent `forge deploy <env>` can reference the same tag even when the working tree has changed between phases.
The original bug this struct closes: `forge build` tags an image `<reg>/<svc>:<git-describe>` (which includes `-dirty` when the working tree has untracked or modified files), then `forge deploy` independently computes a tag — and the two diverge whenever a working-tree mutation between the two phases flips the dirty bit, or when the two phases use different git commands altogether (build uses `git describe --tags --always --dirty`, deploy used `git rev-parse --short HEAD`). The state file fixes both by making build authoritative.
Wire format is JSON; fields use snake_case for readability when a user peeks at the file by hand. PushedAt is RFC3339 so a human can eyeball "how stale is this?" without a parser.
func ReadBuildState ¶
func ReadBuildState(projectDir, env string) (*BuildState, error)
ReadBuildState loads the per-env build-state file. Returns (nil, nil) when the file is missing — that's the "deploy-without-build" path (CI with a separate build job, or the user running `forge deploy` on a fresh checkout) and the caller falls through to resolveImageTag. Returns (nil, err) for malformed JSON or unreadable files; callers should not silently swallow these because they mean the state file exists but can't be trusted.
type BuildVariant ¶
type BuildVariant struct {
Name string `json:"name"`
Ldflags []string `json:"ldflags,omitempty"`
BuildTags []string `json:"build_tags,omitempty"`
GOOS string `json:"goos,omitempty"`
GOARCH string `json:"goarch,omitempty"`
EnvAtBuild map[string]string `json:"env_at_build,omitempty"`
OutputName string `json:"output_name,omitempty"` // default: <service>-<variant>
}
BuildVariant describes one binary produced by a build-only service.
type ClusterEntity ¶
type ClusterEntity struct {
Name string `json:"name"`
// Context is the derived kubectl context (`k3d-<name>`), projected so
// the reconcile / kubeconfig mint can target the cluster without
// re-deriving the prefix.
Context string `json:"context,omitempty"`
Config string `json:"config,omitempty"`
// Network is the derived docker network this cluster joins —
// `k3d-<owner.name>` for a secondary, empty for an owner cluster.
Network string `json:"network,omitempty"`
// RegistryInherit is the derived registry-inherit flag — true when an
// `owner` is set (forge mirrors the owner's registry onto this
// cluster's node), false for an owner cluster.
RegistryInherit bool `json:"registry_inherit,omitempty"`
Servers int `json:"servers,omitempty"`
Agents int `json:"agents,omitempty"`
APIPort int `json:"api_port,omitempty"`
// Ingress, when true, installs the Gateway API stack (pinned Gateway-API
// CRDs + the Envoy Gateway controller via helm + the `eg` GatewayClass)
// into this cluster after it's ensured. A fresh k3d cluster ships none of
// these; an env whose Gateway/HTTPRoute/GRPCRoute resources land on this
// cluster needs it on. Idempotent (helm upgrade --install + kubectl apply).
//
// This is the IMPERATIVE install. An env that declares its Gateway API
// controller DECLARATIVELY as a forge.HelmChart platform dep
// (Bundle.helm_charts) leaves Ingress false and sets HostPorts true.
Ingress bool `json:"ingress,omitempty"`
// HostPorts, when true, merges the generated deploy/k3d-ports.yaml Gateway
// listener host-port fragment into the k3d config at create time. Ingress
// IMPLIES this (an imperatively-installed Gateway also needs the host
// ports). Set HostPorts explicitly when the controller is installed
// declaratively (Ingress=false) but the cluster still hosts a Gateway
// whose listeners must be host-mapped at create time.
HostPorts bool `json:"host_ports,omitempty"`
}
ClusterEntity mirrors the kcl/schema.k Cluster — a k3d cluster forge ensures exists before deploying. The reconcile (clusterPhase) reads these and runs `k3d cluster create` for any that are absent.
Ownership is a REFERENCE: a secondary cluster names its `owner` Cluster; the KCL render layer DERIVES the joined network (`k3d-<owner.name>`, projected into Network) and the registry-inherit behavior (projected into RegistryInherit=true). An owner cluster projects an empty Network and RegistryInherit=false. There is no "primary" field and no most-X heuristic.
type CodemodFn ¶
type CodemodFn func(projectDir string) (CodemodReport, error)
CodemodFn is the contract every per-version codemod implements. projectDir is the absolute path to the project root (the dir containing forge.yaml).
type CodemodReport ¶
type CodemodReport struct {
// Auto is the list of mechanical rewrites the codemod applied,
// each formatted as a single human-readable line ("removed
// ApplyDeps in pkg/app/setup.go:42-48", etc.). Order matters for
// the UPGRADE_NOTES.md output — keep insertion order.
Auto []string
// Manual is the list of items the codemod identified but didn't
// rewrite, each with a file:line reference so the LLM can land
// on the right spot. Reasons range from "pattern didn't match
// the conservative shape we auto-rewrite" to "needs intent
// inspection".
Manual []ManualItem
// VerifyCommands are the commands the user should run after the
// codemod completes. Defaults to the triple-gate when the
// codemod doesn't override.
VerifyCommands []string
}
CodemodReport summarizes one upgrade codemod run. Auto entries are the deterministic rewrites the codemod applied; Manual entries are observations the codemod made about the project that need LLM/user review (e.g. ambiguous nil-checks it left in place).
type ComposeDeploy ¶
type ComposeDeploy struct {
ComposeFile string `json:"compose_file,omitempty"`
Service string `json:"service,omitempty"`
EnvFile string `json:"env_file,omitempty"`
}
ComposeDeploy is the deploy block for a docker-compose service.
type CronJobEntity ¶
type CronJobEntity struct {
Name string `json:"name"`
Schedule string `json:"schedule,omitempty"` // cron expr or @hourly etc.
Image string `json:"image,omitempty"`
Command []string `json:"command,omitempty"`
EnvVars []KCLEnvVar `json:"env_vars,omitempty"`
Platform string `json:"platform,omitempty"`
}
CronJobEntity is one cron-shaped binary from rendered KCL. Empty Schedule means "one-shot Job" (deploy waits for `condition=complete`); non-empty Schedule means "CronJob" (deploy doesn't wait).
type DNSRecordEntity ¶
type DNSRecordEntity struct {
Host string `json:"host"`
Type string `json:"type"`
// Target is the expected value (LB IP for A/AAAA, CNAME target). Often
// unknown at author time (ephemeral LB IP), so optional.
Target string `json:"target,omitempty"`
Reason string `json:"reason,omitempty"`
}
DNSRecordEntity mirrors the kcl/schema.k DNSRecord — a DNS record a deploy depends on. forge can't authoritatively verify external DNS, so this is a render-time checklist entry (and a `--check` note), not a hard block. Target is the expected value (LB IP / CNAME target) when known.
type DeployConfigEntity ¶
type DeployConfigEntity struct {
Type string // "host" | "cluster" | "external" | "compose" | "build-only"
Host *HostDeploy // populated when Type=="host"
Cluster *K8sCluster // populated when Type=="cluster"
External *ExternalDeploy // populated when Type=="external"
Compose *ComposeDeploy // populated when Type=="compose"
BuildOnly *BuildOnlyDeploy // populated when Type=="build-only"
}
DeployConfigEntity is the dispatched-by-type view of a service's deploy block. The raw JSON shape is a tagged union — Type carries the tag; exactly one of Host/Cluster/External/Compose/BuildOnly is non-nil after [dispatchServiceDeploy] runs.
type DockerBuild ¶
type DockerBuild struct {
OutputName string `json:"output_name,omitempty"`
Dockerfile string `json:"dockerfile,omitempty"`
Platform string `json:"platform,omitempty"`
Target string `json:"target,omitempty"`
BuildArgs map[string]string `json:"build_args,omitempty"`
// Registry is the push/tag target for THIS service's image
// (registry-host[/namespace]). Empty falls back to the project-level
// forge.yaml docker.registry (then the project name).
Registry string `json:"registry,omitempty"`
// BuildContexts maps a `docker buildx --build-context name=value` entry
// THIS service's Dockerfile needs (a sibling-checkout path the Dockerfile
// `COPY --from=name`s, a `docker-image://` override, …). Same value shapes
// as config.DockerConfig.BuildContexts. Empty falls back to the
// project-level forge.yaml docker.build_contexts.
BuildContexts map[string]string `json:"build_contexts,omitempty"`
}
DockerBuild mirrors the kcl/schema.k DockerBuild — the per-service container image build. Reuses forge's existing docker primitives (tag/registry/push/build-contexts) as behavior; these fields select the dockerfile/platform/target/build_args.
Registry + BuildContexts are the per-service `docker` facts that are NOT expressible in a Dockerfile — the push target for THIS service's image and the named build contexts THIS service's Dockerfile `COPY --from=`s. Each falls back to the project-level forge.yaml `docker.{registry,build_contexts}` when unset, so a single-image project keeps declaring them once at the top level. KCL renders per env, so these are per-service AND per-env.
type EnvBinding ¶
type EnvBinding struct {
// Release is the version label bound to this env (e.g. "v1.4.0").
Release string `json:"release"`
// Resolved maps the bare image name → the canonical `sha256:...` digest
// this env will deploy. A snapshot of the release's shared digests at
// promote time.
Resolved map[string]string `json:"resolved"`
// PromotedAt is RFC3339 wall-clock — when this binding was written.
PromotedAt string `json:"promoted_at"`
}
EnvBinding records that an env runs a specific release, with the per-image digests resolved at promote time. Resolving at promote (not deploy) time is what makes "the bytes that passed staging ARE the bytes in prod" a checkable invariant: the digests are frozen into the binding the moment the env is promoted.
type EnvReleases ¶
type EnvReleases struct {
// Bindings maps env name → the release bound to it.
Bindings map[string]EnvBinding `json:"bindings"`
}
EnvReleases is the env→release binding ledger written by `forge promote`. A binding is a pure pointer: env `<name>` runs release `<version>`. The resolved per-image digests are snapshotted alongside the version so a deploy can pin them without re-reading the (possibly moved/edited) release file, and so the binding is self-describing when a human peeks at it.
func ReadEnvReleases ¶
func ReadEnvReleases(projectDir string) (*EnvReleases, error)
ReadEnvReleases loads the binding ledger. Returns a zero-value (non-nil) EnvReleases with an empty Bindings map when the file is missing, so callers can range/lookup without a nil guard.
type ExplainEntry ¶
type ExplainEntry struct {
OutputPath string `json:"output_path"`
Sources []string `json:"sources,omitempty"`
Kind string `json:"kind"` // "service-handler", "service-mock", "entity-orm", "config", "contract", "frontend"
Notes []string `json:"notes,omitempty"`
Skipped bool `json:"skipped,omitempty"`
}
ExplainEntry is one row in the per-file provenance log.
type ExternalDeploy ¶
type ExternalDeploy struct {
DeployCmd string `json:"deploy_cmd,omitempty"`
RollbackCmd string `json:"rollback_cmd,omitempty"`
HealthCmd string `json:"health_cmd,omitempty"`
EnvFile string `json:"env_file,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
ExternalDeploy is the deploy block for a generic shell-command deploy target — Fly.io / Cloudflare Workers / Cloud Run / ECS / Vercel / etc. The forge-side ExternalProvider exec's DeployCmd via `sh -c` after substituting ${IMAGE}/${TAG}/${SERVICE}/etc. and runs HealthCmd / RollbackCmd through the same path.
type ExternalSecretEntity ¶
type ExternalSecretEntity struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Keys []string `json:"keys"`
// Reason is a short human note: why the deploy needs this Secret. Shown
// in the checklist so the operator knows what breaks if it's absent.
Reason string `json:"reason,omitempty"`
// ValueGroup is the shared id tying this Secret to other ExternalSecrets
// that must carry the same logical value (cross-secret byte-match).
// Empty => a standalone prereq (no byte-match group).
ValueGroup string `json:"value_group,omitempty"`
}
ExternalSecretEntity mirrors the kcl/schema.k ExternalSecret — an out-of-band Secret a deploy DEPENDS ON but forge does NOT create. forge reads these to print a render-time prerequisite checklist and to drive the deploy preflight (a declared-required-but-absent Secret/key BLOCKS, reusing the same SecretGetter as the secretKeyRef preflight). ValueGroup, when set, ties this Secret to others that must carry the SAME logical value (the cross-secret byte-match group).
type FirebaseBundleDir ¶
FirebaseBundleDir is one extra pre-built static directory assembled into the hosting site alongside the frontend's own build output. Dest empty means the site root.
type FirebaseHostingDeploy ¶
type FirebaseHostingDeploy struct {
Project string `json:"project"`
Site string `json:"site"`
Target string `json:"target,omitempty"`
PublicDir string `json:"public_dir"`
BasePath string `json:"base_path,omitempty"`
Bundle []FirebaseBundleDir `json:"bundle,omitempty"`
Rewrites []map[string]any `json:"rewrites,omitempty"`
}
FirebaseHostingDeploy mirrors the kcl/schema.k FirebaseHosting schema. The forge-side FirebaseProvider builds the frontend, assembles public_dir + Bundle dirs into a staging tree honoring BasePath, writes a firebase.json + .firebaserc, and runs `firebase deploy`.
type ForgeDescriptor ¶
type ForgeDescriptor struct {
Services []codegen.ServiceDef `json:"services"`
Configs []codegen.ConfigMessage `json:"configs"`
}
ForgeDescriptor is the top-level JSON structure written by mode=descriptor. It aggregates all data the generate.go pipeline needs from proto descriptors.
type FrictionEntry ¶
type FrictionEntry struct {
// Schema is the record-shape version (frictionSchemaVersion at write
// time). Readers treat absent/zero as version 1.
Schema int `json:"schema"`
// ID is a short content hash ("fr-" + 10 hex chars) over the
// recorded-at instant and the entry payload. Content-derived (not a
// counter) so concurrent writers and merged branches can't collide
// on allocation.
ID string `json:"id"`
// RecordedAt is the capture instant, RFC3339 UTC.
RecordedAt time.Time `json:"recorded_at"`
// ForgeVersion is the binary that recorded the entry (buildinfo) —
// lets upstream triage check whether the friction predates a fix.
ForgeVersion string `json:"forge_version"`
// Severity is one of p0 | p1 | p2 | note.
Severity string `json:"severity"`
// Area is a free-form tag (codegen, frontend, deploy, ...).
Area string `json:"area,omitempty"`
// Source is a free-form origin tag (agent name, workflow id, human).
Source string `json:"source,omitempty"`
// Context holds file:line refs or commands that anchor the entry.
Context []string `json:"context,omitempty"`
// Text is the friction description.
Text string `json:"text"`
}
FrictionEntry is one record in .forge/friction.jsonl. Every entry is self-contained (no cross-line references) so lines can be unioned, reordered, or truncated without corrupting neighbours. Field tags are stable — downstream tooling parses this; see frictionSchemaVersion for the evolution contract.
type FrontendDeployEntity ¶
type FrontendDeployEntity struct {
Type string `json:"type"` // "firebase" (host/cluster/external/compose reserved for future frontend targets)
// Firebase is populated when Type=="firebase". The Firebase Hosting
// deploy spec — build output dir, target site/project, base-path
// mount, and any extra static dirs to assemble into the same site.
Firebase *FirebaseHostingDeploy `json:"-"`
}
FrontendDeployEntity carries the deploy discriminator for a frontend. Today the only populated variant is FirebaseHosting (Type=="firebase"); the Firebase field is non-nil exactly when Type=="firebase". The Type discriminator still drives the build skip-list; the embedded variant blocks carry the per-target config the deploy dispatch needs. Adding new dispatch keys (e.g. a Vercel variant) later is a pure additive change — a new pointer field + a new Type string.
func (*FrontendDeployEntity) UnmarshalJSON ¶
func (d *FrontendDeployEntity) UnmarshalJSON(data []byte) error
UnmarshalJSON dispatches the frontend deploy block by its `type` discriminator. An absent / null deploy leaves the zero value (Type==""). Today only "firebase" carries a typed body; unknown types are retained as the bare Type string so a forward-compatible KCL render (a deploy variant this binary predates) degrades to "skip build / no dispatch" rather than erroring the whole render.
type FrontendEntity ¶
type FrontendEntity struct {
Name string `json:"name"`
Type string `json:"type,omitempty"` // "nextjs" | "vite-spa" | "react-native"
Path string `json:"path"`
DevRunner string `json:"dev_runner,omitempty"` // "npm" (default) | "pnpm" | "yarn"
Port int `json:"port,omitempty"`
EnvFile string `json:"env_file,omitempty"`
EnvVars []KCLEnvVar `json:"env_vars,omitempty"`
Deploy *FrontendDeployEntity `json:"deploy,omitempty"`
}
FrontendEntity is one frontend from rendered KCL. Frontends are host-only in the dev loop (no in-cluster Deployment for the dev env); the DevRunner field selects npm/pnpm/yarn.
Deploy is the optional discriminator that lets `forge build` skip the production build for frontends that ship via host-mode dev server only (no production artifact ever consumed). When absent (legacy projects whose KCL doesn't emit a frontend `deploy` block) callers fall back to "always build", preserving the pre-discriminator behaviour. Unlike ServiceEntity.Deploy this is a thin Type-only struct — frontends don't carry per-mode config blocks on the Go side; the type discriminator is the only thing the build pipeline needs to make the skip/build decision.
type GRPCRouteEntity ¶
type GRPCRouteEntity struct {
Name string `json:"name"`
Gateway string `json:"gateway"`
Listener string `json:"listener"`
Service string `json:"service"`
Port int `json:"port"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
RawPolicy string `json:"raw_policy,omitempty"`
}
GRPCRouteEntity mirrors the kcl/schema.k GRPCRoute. Shape matches HTTPRouteEntity — the distinction is the rendered Gateway API resource kind (GRPCRoute vs HTTPRoute).
type GatewayAddressEntity ¶
GatewayAddressEntity mirrors the kcl/schema.k GatewayAddress — one entry in the Gateway's spec.addresses, pinning it to a load-balancer address. Type is "NamedAddress" (Value is a GKE reserved static-IP reservation name) or "IPAddress" (Value is a literal IP).
type GatewayEntity ¶
type GatewayEntity struct {
Name string `json:"name"`
GatewayClassName string `json:"gateway_class_name,omitempty"`
Host string `json:"host,omitempty"`
TLS *GatewayTLSEntity `json:"tls,omitempty"`
Listeners []GatewayListenerEntity `json:"listeners,omitempty"`
RawPolicy string `json:"raw_policy,omitempty"`
Addresses []GatewayAddressEntity `json:"addresses,omitempty"`
}
GatewayEntity mirrors the kcl/schema.k Gateway. Listeners are inlined. Tls is nil when the gateway is plaintext.
type GatewayListenerEntity ¶
type GatewayListenerEntity struct {
Name string `json:"name"`
Port int `json:"port"`
Protocol string `json:"protocol"`
PathPrefix string `json:"path_prefix,omitempty"`
}
GatewayListenerEntity mirrors the kcl/schema.k GatewayListener. Protocol is "HTTP" | "HTTPS" | "H2C".
type GatewayTLSEntity ¶
type GatewayTLSEntity struct {
CertIssuer string `json:"cert_issuer,omitempty"`
SecretName string `json:"secret_name,omitempty"`
Certmap string `json:"certmap,omitempty"`
Mode string `json:"mode,omitempty"`
}
GatewayTLSEntity is the TLS block on a Gateway. Mode selects the cert origin: "cert_manager" (default — cert-manager Certificate emitted alongside the Gateway, CertIssuer names a ClusterIssuer), "mkcert" (Secret populated host-side by `forge cluster up` via the mkcert binary; CertIssuer unused), or "gke_certmap" (GCP Certificate Manager map named by Certmap terminates TLS; CertIssuer / SecretName unused — the GKE Gateway controller binds the map via the `networking.gke.io/certmap` annotation forge stamps on the Gateway).
type GenStep ¶
type GenStep struct {
// Name is the human-readable label printed by --plan / --explain
// and used in error wrapping. Replaces the legacy "Step Nx" comment
// numbering — make these stable; tests pin the order.
Name string
// Gate returns true when the step should execute for this context.
// Gates MUST be pure: no I/O, no mutation. They're called by
// stability tests and may be called repeatedly by future watch-mode
// dispatchers.
Gate func(*pipelineContext) bool
// GateReason is the human-readable explanation rendered when Gate
// returns false. Printed by `--plan` `[SKIP]` lines and by the
// `--verbose` `⏩ skipped:` lines instead of the function-name-derived
// `gate: <gateName>` label, which carried no semantic signal for
// users debugging "why didn't generate touch X?".
//
// Conventions:
// - Phrase as the FALSE condition ("no services in forge.yaml",
// "features.codegen=false") so the user reads the skip reason
// directly without having to mentally invert a positive statement.
// - Keep under ~60 chars to fit a single `[SKIP] <name> (<reason>)`
// line at the column width `--plan` already uses (44 + reason).
// - Empty string falls back to the legacy "gate <gateName> returned
// false" rendering — useful during incremental adoption.
GateReason string
// Run is the action. It may mutate ctx (parsed services, derived
// flags, checksums) so subsequent steps can reuse the work. Errors
// abort the pipeline; warnings should be logged in-step.
Run func(*pipelineContext) error
// Tag categorizes the step for future filtering. Conventional
// values: "config", "proto", "codegen", "migrations", "frontend",
// "deploy", "tools", "validate".
Tag string
}
GenStep is one ordered unit of the generate pipeline. Steps are pure data plus two functions: Gate decides whether the step should run for the current pipelineContext (must be side-effect free), and Run executes the step. Tag categorizes the step for filtering by future callers (--plan output, `forge dev` watch-mode dispatch).
type GoBuild ¶
type GoBuild struct {
OutputName string `json:"output_name,omitempty"`
Cmd string `json:"cmd"`
GOOS string `json:"goos,omitempty"`
GOARCH string `json:"goarch,omitempty"`
Ldflags []string `json:"ldflags,omitempty"`
Tags []string `json:"tags,omitempty"`
Flags []string `json:"flags,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
GoBuild mirrors the kcl/schema.k GoBuild. Cmd is the go-build target package (e.g. "./cmd/trader"); the rest are the cross-compile + flag knobs build.go passes straight to `go build`.
type HTTPRouteEntity ¶
type HTTPRouteEntity struct {
Name string `json:"name"`
Gateway string `json:"gateway"`
Listener string `json:"listener"`
Service string `json:"service"`
Port int `json:"port"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
RawPolicy string `json:"raw_policy,omitempty"`
}
HTTPRouteEntity mirrors the kcl/schema.k HTTPRoute. Service is a backend Service name; Port is the backend port.
type HandlerPath ¶
type HandlerPath struct {
Service string `json:"service"`
Method string `json:"method"`
Path string `json:"path"`
}
HandlerPath is a single RPC route the binary will register. Service is the fully-qualified proto service name (package.Service); Method is the RPC method name; Path is the canonical Connect URL (`/<Service>/<Method>`).
type HelmChartEntity ¶
type HelmChartEntity struct {
Name string `json:"name"`
// Chart is the chart name for a repo chart; empty for an OCI chart.
Chart string `json:"chart,omitempty"`
// Repo is the chart-repo URL; mutually exclusive with OCI.
Repo string `json:"repo,omitempty"`
// OCI is the OCI chart ref; mutually exclusive with Repo.
OCI string `json:"oci,omitempty"`
// Version is the pinned chart version.
Version string `json:"version"`
// Namespace is the namespace the chart renders into (helm template -n).
Namespace string `json:"namespace"`
// Values is the helm values overlay, passed through verbatim.
Values map[string]any `json:"values,omitempty"`
// CRDs selects which forge-owned CRD bundle to apply FIRST
// (Established-gated) before the chart's controllers: "" (none),
// "gateway-api", or "cert-manager". The chart is rendered --skip-crds,
// so forge owns the CRD surface.
CRDs string `json:"crds,omitempty"`
// Manifests are consumer-declared raw k8s manifest dicts that ride this
// chart's `--target` (the `eg` GatewayClass, cert-manager ClusterIssuers)
// — the cluster-scoped instances the chart's controller reconciles but
// the chart itself doesn't ship. Stamped with the chart's app-label and
// applied AFTER its controllers; excluded from a bare app deploy.
Manifests []any `json:"manifests,omitempty"`
}
HelmChartEntity is one declared platform dependency from rendered KCL (the `output.helm_charts` projection of forge.HelmChart). It is the declaration only — forge expands it via `helm template --skip-crds` Go-side (internal/cluster.RenderHelmChart) and folds the manifests into the apply stream, selected by `--target=<name>`. helm is a RENDERER, not an installer: there is no release, no `helm install`.
type HostDeploy ¶
type HostDeploy struct {
Runner string `json:"runner,omitempty"` // "go-run" | "air" | "binary" | "delve"
AirConfig string `json:"air_config,omitempty"` // path relative to project root, default .air.toml
EnvVars []KCLEnvVar `json:"env_vars,omitempty"` // KCL-declared per-env config
SecretsFile string `json:"secrets_file,omitempty"` // path relative to project root; gitignored dotenv
DelvePort int `json:"delve_port,omitempty"` // when Runner=="delve"; default 2345
// WorkingDir overrides the launched subprocess's working directory.
// Relative paths resolve against the project root. Use this for
// cross-repo binaries whose runner config (e.g. Air's build_cmd
// paths) resolves relative to a sibling repo. Default: project root.
WorkingDir string `json:"working_dir,omitempty"`
}
HostDeploy is the deploy block for a service that runs as a host process during `forge up --env=<env>`. The Runner field selects the dispatch (go-run / air / binary / delve) and is consumed by [runHostServiceWithRunner] + the up orchestrator.
Env composition splits config from secrets:
- EnvVars: KCL-declared per-env config (DATABASE_URL, NATS_URL, LOG_LEVEL, …). Reproducible, version-controlled.
- SecretsFile: path to a gitignored dotenv carrying JUST secrets (STRIPE_*, SUPABASE_*, JWT_PUBLIC_KEY, …). Loaded first; EnvVars is layered on top so KCL wins on conflict.
Previously HostDeploy carried a single `env_file` that conflated config and secrets and silently drifted from K8sCluster services (which already saw config via the Deployment's `env` block).
type IngressURL ¶
type IngressURL struct {
Route string `json:"route"`
Kind string `json:"kind"` // "HTTPRoute" | "GRPCRoute"
URL string `json:"url"`
Gateway string `json:"gateway"`
Listener string `json:"listener"`
Service string `json:"service"`
Port int `json:"port"`
Warning string `json:"warning,omitempty"`
}
IngressURL is one row of the dev ingress URL table.
Warning is set (and URL may be empty) when the route references a gateway/listener that doesn't resolve. Surfacing this as a row rather than aborting matches the audit-shaped commands' graceful-degradation posture.
type K8sCluster ¶
type K8sCluster struct {
// Env-wide knobs — same value across every service in a deploy
// group.
Cluster string `json:"cluster,omitempty"`
Namespace string `json:"namespace,omitempty"`
Registry string `json:"registry,omitempty"`
Domain string `json:"domain,omitempty"`
// Per-service knobs.
Replicas int `json:"replicas,omitempty"`
Platform string `json:"platform,omitempty"` // GOARCH override; empty = use forge.yaml deploy.target_arch
Ports []int `json:"ports,omitempty"`
EnvVars []KCLEnvVar `json:"env_vars,omitempty"`
}
K8sCluster is the deploy block for a cluster-mode service. Mirrors the JSON contract emitted by `_render_k8s_cluster` in kcl/render.k.
Cluster/Namespace/Registry are mandatory env-wide fields the KCL-side `K8sCluster` schema declares as required — an empty value here indicates a malformed render rather than a legacy shape.
Ingress used to be a per-service field on this struct; it now lives at the Bundle level as Gateway/HTTPRoute/GRPCRoute (see KCLEntities.Gateways etc.). Routes reference services by name.
type KCLEntities ¶
type KCLEntities struct {
// Clusters are the k3d clusters forge ensures exist at the head of
// `forge up` before any workload deploys. Empty for an env that
// declares no clusters (today's no-ensure behavior). Ownership is
// implicit via Cluster.Network / Cluster.RegistryMirror — there is
// no "primary" cluster.
Clusters []ClusterEntity `json:"clusters,omitempty"`
// KubeconfigSecrets are cross-cluster kubeconfigs forge mints fresh
// each up (at the cluster→deploy boundary) and applies as k8s Secrets.
KubeconfigSecrets []KubeconfigSecretEntity `json:"kubeconfig_secrets,omitempty"`
Services []ServiceEntity `json:"services,omitempty"`
Operators []OperatorEntity `json:"operators,omitempty"`
Frontends []FrontendEntity `json:"frontends,omitempty"`
CronJobs []CronJobEntity `json:"cronjobs,omitempty"`
Gateways []GatewayEntity `json:"gateways,omitempty"`
HTTPRoutes []HTTPRouteEntity `json:"http_routes,omitempty"`
GRPCRoutes []GRPCRouteEntity `json:"grpc_routes,omitempty"`
// HelmCharts are the env's declared platform deps (forge.HelmChart),
// each a renderable with a NAME the `--target` axis selects. forge
// expands them via helm-as-a-RENDERER and folds the manifests into the
// apply stream. Empty => no platform deps. See HelmChartEntity.
HelmCharts []HelmChartEntity `json:"helm_charts,omitempty"`
// SecretProvider is the bundle-level secret provider declaration
// (WHERE secret values come from for this env). Nil when the bundle
// declares no provider — preserving today's no-provider behavior.
SecretProvider *SecretProviderEntity `json:"secret_provider,omitempty"`
// RequiredSecrets are the env's declared external Secret prerequisites
// (forge.ExternalSecret) — out-of-band Secrets the deploy depends on but
// forge does NOT create. Drive the render-time checklist + the deploy
// preflight BLOCK on a declared-required-but-absent Secret/key. Empty =>
// no declared Secret prereqs (today's behavior).
RequiredSecrets []ExternalSecretEntity `json:"required_secrets,omitempty"`
// RequiredDNS are the env's declared DNS-record prerequisites
// (forge.DNSRecord) — surfaced as a render-time checklist note (forge
// can't authoritatively verify external DNS). Empty => none.
RequiredDNS []DNSRecordEntity `json:"required_dns,omitempty"`
// ManifestNamespace is the namespace stamped on the rendered k8s
// manifests (`manifests[].metadata.namespace`), recovered even when
// the project's main.k omits the `output = forge.render(_bundle)`
// entity echo. Some projects deliberately render only `manifests`
// (e.g. to keep the deployable image refs single-prefixed), which
// leaves the entity contract — and therefore every cluster-shaped
// service's K8sCluster.namespace — absent. We derive the namespace
// from the manifests so the declared-namespace resolution
// (k8sClusterNamespaceForEnv → forge deploy/smoke/secrets) keeps
// working without forcing the user to echo `output` or pass
// --namespace. Empty when the render carries no namespaced manifests.
ManifestNamespace string `json:"-"`
// ManifestServiceNames are the metadata.name of every k8s Service in
// the rendered `manifests` stream — the raw Service objects a project
// injects via KCL (e.g. `additional_manifests`) that carry no typed
// forge entity. forge emits no Service for a forge.Operator, so an
// operator that ALSO fronts a Connect handler is exposed by a
// hand-authored k8s Service manifest; its name lives ONLY here, not in
// Services (typed forge.Service) or forge.yaml. The ingress audit
// unions these into the known-backend set so a route targeting such a
// Service resolves instead of false-erroring "unknown service". Empty
// when the render carries no raw Service manifests.
ManifestServiceNames []string `json:"-"`
// ManifestImageTags maps a (registry-less) image NAME to the tag the
// rendered Deployment/Statefulset/Job manifests reference for it —
// recovered from `manifests[].spec.template.spec.containers[].image`.
// This is the env's RESOLVED image_tag (the `option("image_tag") or
// "<default>"` value baked into the manifest image refs), the exact
// tag `forge deploy <env>` will pull. `forge build --env <env>` reads
// it back so its default build tag MATCHES the deploy tag by
// construction — closing the build/deploy tag-divergence footgun
// where build tagged from git-describe but deploy referenced the
// env's literal default (e.g. "staging"), pushing one tag and
// deploying another → ImagePullBackOff. nil/empty when the render
// carries no Deployment-shaped manifests.
ManifestImageTags map[string]string `json:"-"`
}
KCLEntities is the typed, dispatched view of the JSON the sibling KCL deploy module emits. The typed schema module exports the polymorphic `deploy: HostDeploy | K8sCluster | External | Compose | BuildOnly` union per service; the JSON discriminator is `deploy.type ∈ {"host","cluster","external","compose","build-only"}` (services only — operators/cronjobs are always cluster-shaped).
Callers (`forge build --env`, `forge deploy <env>`, `forge up --env`, `forge run <svc>`) read this rather than reaching back into forge.yaml because deployment placement is a per-env decision that lives in the KCL layer, not on services[] in the project config.
func RenderKCL ¶
func RenderKCL(ctx context.Context, projectDir, env string) (*KCLEntities, error)
RenderKCL shells `kcl run deploy/kcl/<env>/ -o json`, parses the output, and dispatches each service's deploy block by Type into the right pointer. Returns an error when:
- The KCL directory doesn't exist (env not configured)
- `kcl` is not on PATH (caller needs to install it)
- The JSON output is malformed
- A service's deploy.type is none of "host"/"cluster"/"build-only"
The override env var FORGE_KCL_RENDER_FIXTURE points at a JSON file whose contents are read in lieu of shelling kcl. Used by unit tests so they can exercise the dispatch logic without a real KCL toolchain.
func (*KCLEntities) BuildOnlyServiceNames ¶
func (e *KCLEntities) BuildOnlyServiceNames() []string
BuildOnlyServiceNames returns the names of every service with Deploy.Type == "build-only". Build emits binaries (per variant) for these; deploy skips them entirely.
func (*KCLEntities) ClusterServiceNames ¶
func (e *KCLEntities) ClusterServiceNames() []string
ClusterServiceNames returns the names of every service with Deploy.Type == "cluster". Used by deploy / up to choose which services participate in `kubectl apply` and rollout-wait.
func (*KCLEntities) FindService ¶
func (e *KCLEntities) FindService(name string) *ServiceEntity
FindService returns the named service from the entity set, or nil. Convenience for callers that need to look up a service before dispatching on Deploy.Type.
func (*KCLEntities) HostServiceNames ¶
func (e *KCLEntities) HostServiceNames() []string
HostServiceNames returns the names of every service with Deploy.Type == "host". The build skip-list and the up orchestrator's host phase both consume this.
type KCLEnvVar ¶
type KCLEnvVar struct {
Name string `json:"name"`
Value string `json:"value,omitempty"`
SecretRef string `json:"secret_ref,omitempty"`
SecretKey string `json:"secret_key,omitempty"`
ConfigMapRef string `json:"config_map_ref,omitempty"`
ConfigMapKey string `json:"config_map_key,omitempty"`
}
KCLEnvVar is a single env var entry from the rendered KCL. Distinct type so we don't pull in the project-config EnvVar (which carries codegen-specific fields the KCL renderer doesn't know about).
Three projection channels mirror the KCL EnvVar schema (kcl/schema.k):
- Value: inline literal. The dominant case host-mode consumes.
- SecretRef + SecretKey: cluster-mode projection from a Secret (Deployment.env.valueFrom.secretKeyRef). No host equivalent — host-mode picks the value up from the gitignored secrets_file.
- ConfigMapRef + ConfigMapKey: cluster-mode projection from a forge-generated ConfigMap.
SecretRef / ConfigMapRef are surfaced (rather than dropped) so the `forge doctor parity` diff can attribute cluster-side projected env vars to their source rather than treating an empty Value as "unset".
type KubeconfigSecretEntity ¶
type KubeconfigSecretEntity struct {
Name string `json:"name"`
InCluster string `json:"in_cluster"`
TargetCluster string `json:"target_cluster"`
ContextName string `json:"context_name"`
Key string `json:"key,omitempty"`
Namespace string `json:"namespace,omitempty"`
Reachability string `json:"reachability,omitempty"`
}
KubeconfigSecretEntity mirrors the kcl/schema.k KubeconfigSecret — a cross-cluster kubeconfig forge mints FRESH each up and stores as a k8s Secret. The mint step (mintKubeconfigSecrets) resolves the target's endpoint at runtime and never persists the IP.
type ManualItem ¶
type ManualItem struct {
File string // relative to projectDir
Line int // 1-based; 0 means file-level (no specific line)
Reason string // short, paste-into-an-LLM-prompt-friendly
}
ManualItem is one entry in CodemodReport.Manual — file:line pairs the LLM/user should look at after the codemod completes.
type MapNode ¶
type MapNode struct {
Path string `json:"path"`
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Ownership string `json:"ownership,omitempty"`
Flags []string `json:"flags,omitempty"`
Children []*MapNode `json:"children,omitempty"`
}
MapNode is one entry in the project tree. Children is empty for files; for directories it is sorted alphabetically (dirs first, then files, each group ordered by name).
type OperatorEntity ¶
type OperatorEntity struct {
Name string `json:"name"`
Image string `json:"image,omitempty"`
CRDs []string `json:"crds,omitempty"`
ClusterRBAC *RBACSpec `json:"cluster_rbac,omitempty"`
LeaderElection bool `json:"leader_election,omitempty"`
Replicas int `json:"replicas,omitempty"`
Platform string `json:"platform,omitempty"`
EnvVars []KCLEnvVar `json:"env_vars,omitempty"`
}
OperatorEntity is one operator from rendered KCL. Operators are always cluster-mode (no host/build-only equivalent) so the type is flat.
type RBACSpec ¶
type RBACSpec struct{}
RBACSpec is a placeholder for an operator's cluster RBAC. We only surface that it's set; the actual RBAC content is consumed by the KCL renderer that produces the YAML manifests.
type Release ¶
type Release struct {
// Version is the human-readable label (semver, "v1.4.0") and the ledger
// filename stem.
Version string `json:"release"`
// Git captures the source provenance of the build so a reviewer can tie a
// release back to a commit. Best-effort (empty on a non-git tree).
Git ReleaseGit `json:"git"`
// CreatedAt is RFC3339 wall-clock. Informational across invocations.
CreatedAt string `json:"created_at"`
// Artifacts maps the bare image name (the key services match against
// `svc.image`, e.g. "control-plane", "reliant") → its resolved identity.
Artifacts map[string]ReleaseArtifact `json:"artifacts"`
}
Release is the ledger written by `forge build --release <version>`. It is the unit of truth for "what bytes are v1.4.0": a version label on top, a content-addressed digest per image underneath. Immutable once cut — promotion advances it across envs BY REFERENCE (a binding), never by rebuild.
func ReadRelease ¶
ReadRelease loads a Release ledger by version. Returns (nil, nil) when the file is missing — the caller decides whether that's an error (a deploy referencing an absent release) or a fall-through.
type ReleaseArtifact ¶
type ReleaseArtifact struct {
// Mode is "shared" (build once, one digest, all envs) for the MVP.
// "variant" (per-env digests) is a documented follow-up, not yet produced.
Mode string `json:"mode"`
// Digests maps a variant key → canonical `sha256:...` digest. For a
// shared artifact the only key is sharedVariantKey ("*").
Digests map[string]string `json:"digests"`
// Platforms is the OS/arch set the captured manifest advertises
// (e.g. ["linux/amd64"]). Informational for the MVP (single-arch amd64);
// the deploy preflight inspects the live image's arch independently.
Platforms []string `json:"platforms,omitempty"`
}
ReleaseArtifact is one image's resolved content-addressed identity within a release. For the MVP every artifact is Mode "shared": Digests has exactly one entry under sharedVariantKey, promoted to every env. The map shape (not a bare string) is the seam for the deferred `variant` mode, where each env's variant_key maps to its own digest.
func (ReleaseArtifact) SharedDigest ¶
func (a ReleaseArtifact) SharedDigest() (string, bool)
SharedDigest returns the digest of a shared artifact (the only mode the MVP produces) and whether it was present. A variant artifact (deferred) returns ("", false) here — its resolution is keyed by the target's variant_key, a follow-up.
type ReleaseGit ¶
type ReleaseGit struct {
Commit string `json:"commit,omitempty"`
Tag string `json:"tag,omitempty"`
Dirty bool `json:"dirty,omitempty"`
}
ReleaseGit is the source provenance recorded in a Release.
type RenderedSecretEntity ¶
type RenderedSecretEntity struct {
Name string `json:"name"`
Keys map[string]RenderedSecretKeyEntity `json:"keys"`
}
RenderedSecretEntity mirrors the kcl/schema.k RenderedSecret — one k8s Secret forge renders from declared sources. Keys maps each in-Secret key to its value source.
type RenderedSecretKeyEntity ¶
type RenderedSecretKeyEntity struct {
From string `json:"from"`
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
}
RenderedSecretKeyEntity mirrors the kcl/schema.k RenderedSecretKey. From is "dotenv" (read .env.<env> at Key) or "literal" (inline Value, dev/e2e only). A "dotenv" key carries no Value; a "literal" key carries no dotenv Key.
type SecretProviderEntity ¶
type SecretProviderEntity struct {
Type string `json:"type"`
Path string `json:"path,omitempty"`
// Secrets is populated for Type=="rendered": the explicit Secret
// declarations (name + per-key source) forge renders + applies per
// cluster. Empty for dotenv/external.
Secrets []RenderedSecretEntity `json:"secrets,omitempty"`
}
SecretProviderEntity is the parsed bundle-level secret provider declaration. Type is "dotenv" | "external" | "rendered". Path is the dotenv path (dotenv only), resolved relative to the project root by the CLI. Secrets is the declared Secret set (rendered only).
type ServiceEntity ¶
type ServiceEntity struct {
Name string `json:"name"`
// Image is the (registry-less) image name. ImageTag, when set, is the
// per-service tag PIN the KCL render layer stamps instead of the
// env-wide tag — surfaced here so audit / parity consumers can see the
// pin rather than inferring an untagged image. The rendered image ref
// (registry + tag resolution) is built KCL-side in _image_ref; this is
// the declaration, not the resolved ref.
Image string `json:"image,omitempty"`
ImageTag string `json:"image_tag,omitempty"`
Deploy DeployConfigEntity `json:"deploy"`
// Build is the polymorphic build declaration — exactly one of
// Go / Docker / Shell is populated according to Build.Type. Mirrors
// Deploy. When the KCL `build` block is absent (a hand-authored
// forge.Service that omits it) Build.Type is "" and callers
// synthesize the GoBuild default via [ServiceEntity.EffectiveBuild].
Build BuildConfigEntity `json:"-"`
EnvVars []KCLEnvVar `json:"env_vars,omitempty"`
Command []string `json:"command,omitempty"`
}
ServiceEntity is one service from rendered KCL. The Deploy field is polymorphic — exactly one of Host / Cluster / BuildOnly is populated according to Deploy.Type. See DeployConfigEntity for the discriminator.
The build side is the polymorphic Build union (Go / Docker / Shell). A ShellBuild is the single shell escape hatch — its cmd / cwd / env / digest contract lives on ShellBuild, dispatched by the external-build dispatcher (see internal/buildtarget).
func (ServiceEntity) EffectiveBuild ¶
func (s ServiceEntity) EffectiveBuild() BuildConfigEntity
EffectiveBuild returns the build declaration build.go should execute for this service, resolving the absent-block case to the synthesized GoBuild default ("./cmd/<name>"). This is the ONE place the default lives — so a hand-authored forge.Service that omits `build`, a project on an older KCL render, and the deploy-as-data bridge all converge on the same answer without build.go re-deriving it.
An EXPLICIT `build` block always wins. When the block is absent the default is deploy-type-aware: only forge-built deploy targets (host, cluster, build-only) synthesize the ./cmd/<name> GoBuild. A `compose` service has NO Go artifact (it's a docker-compose unit), and an `external` service owns its own deploy — synthesizing a GoBuild for either would make forge `go build ./cmd/<name>` a package that doesn't exist (e.g. a sibling-repo binary or a compose aggregator). Those return the zero BuildConfigEntity (Type=="") so goBuildTargetsFromKCL skips them. A service that builds via a shell command declares `build = forge.ShellBuild {...}` explicitly (the single shell hatch), which the first branch returns.
func (ServiceEntity) EffectiveBuildCmd ¶
func (s ServiceEntity) EffectiveBuildCmd() string
EffectiveBuildCmd returns the shell command the external-build dispatcher should run for this service: the effective ShellBuild's Cmd, or "" when the service's effective build isn't a ShellBuild (the dispatcher's "not a shell build" signal). The single shell source after the build-hatch unification — there is no longer a flat Service.build_cmd or an External.build_cmd to fall back to.
func (ServiceEntity) EffectiveBuildCwd ¶
func (s ServiceEntity) EffectiveBuildCwd() string
EffectiveBuildCwd returns the working directory the shell build runs from — the effective ShellBuild's Cwd (empty => the project root). "" for a non-shell build.
func (ServiceEntity) EffectiveBuildEnv ¶
func (s ServiceEntity) EffectiveBuildEnv() map[string]string
EffectiveBuildEnv returns the env-var map merged into the shell build command's environment + substitution map — the effective ShellBuild's Env. nil for a non-shell build.
type ShellBuild ¶
type ShellBuild struct {
OutputName string `json:"output_name,omitempty"`
Cmd string `json:"cmd"`
Cwd string `json:"cwd,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
ShellBuild mirrors the kcl/schema.k ShellBuild — the SINGLE shell escape hatch: a verbatim `sh -c` build command that owns the whole build (and any push).
Execution contract (see internal/buildtarget):
- cwd == Cwd resolved against the project root (relative paths join the dir holding forge.yaml; absolute pass through). Empty Cwd => the project root, so relative paths like scripts/build-image.sh, ../sibling-repo, or docker/Dockerfile resolve as a user expects. A Cwd that doesn't exist on disk is a HARD build failure.
- before exec forge substitutes the ${X} tokens ${IMAGE} ${TAG} ${CODE_VERSION} ${SERVICE} ${TARGETARCH} ${REGISTRY} ${PROJECT_DIR} ${ENV} ${BUILD_CWD}, plus any keys in Env (built-ins win on conflict), into Cmd.
- Env vars are merged into the command's process environment AND the substitution map.
- on success forge captures the pushed digest (best-effort) and writes the build-state file so deploy pins the same tag/digest.
Absorbs the former flat Service.build_cmd / build_cwd / build_env trio (and External.build_cmd) — one declaration surface, one contract.
type SkillAudience ¶
type SkillAudience string
SkillAudience names the consumer of a skill emission. Combined with the per-skill frontmatter `emit:` field (SkillEmit) it decides which skills are written and how their bodies are rendered:
audience=All ("") — every skill, full body (no stripping). Default.
audience=General — emit:general|both skills only; @forge-only blocks stripped.
audience=Forge — emit:forge|both skills only; full body retained.
const ( // SkillAudienceAll disables filtering entirely. Use when bulk-exporting // the canonical catalog (e.g. forge's own .claude/skills/) where the // reader can decide what to surface. SkillAudienceAll SkillAudience = "" // SkillAudienceGeneral targets consumers outside a forge project. The // renderer strips `<!-- @forge-only:start/end -->` blocks from emit:both // skills, and drops emit:forge skills entirely. SkillAudienceGeneral SkillAudience = "general" // SkillAudienceForge targets consumers inside a forge project. Full // body is preserved; emit:general skills are also included. SkillAudienceForge SkillAudience = "forge" )
type SkillEmit ¶
type SkillEmit string
SkillEmit declares which audience(s) a skill is authored for. Read from the YAML frontmatter `emit:` field. Drives the dual-audience compile path: a single SKILL.md source can target general consumers, forge consumers, or both — with `<!-- @forge-only:start/end -->` blocks stripped from the body when emitting to a general audience.
An empty value is treated as SkillEmitForge by [emitMatchesAudience] — legacy skills shipped under templates/project/skills/forge/ pre-date the field and are framework-specific by default.
const ( // SkillEmitForge — framework skills (proto, db, api, etc.) that only // make sense in a forge project. The legacy default. SkillEmitForge SkillEmit = "forge" // SkillEmitGeneral — methodology skills (debug, code-review, etc.) // that apply to any project, forge or not. SkillEmitGeneral SkillEmit = "general" // SkillEmitBoth — the body has both general and framework content, // with the latter inside `@forge-only` blocks. The renderer keeps the // whole body for forge audiences and strips the blocks for general. SkillEmitBoth SkillEmit = "both" )
type SkillListOptions ¶
type SkillListOptions struct {
// IncludeMigrations opts one-time migration skills
// (relevance: migration) back into the listing. Default listings
// exclude them — they only matter while crossing a specific forge
// version transition.
IncludeMigrations bool
}
SkillListOptions tunes ListSkillsAtWithOptions. The zero value is the default-listing behavior (migration skills excluded).
type SkillMetaPublic ¶
type SkillMetaPublic struct {
Path string
Name string
Description string
Scope SkillScope
Emit SkillEmit
// Relevance classifies when the skill is worth surfacing: "" (always —
// the default) or "migration" (one-time upgrade transition; excluded
// from default listings, included via SkillListOptions.IncludeMigrations).
Relevance SkillRelevance
// AppliesFrom / AppliesTo are the migration skill's frontmatter version
// bounds (half-open [from, to) over the project's pinned forge_version),
// passed through verbatim for consumers that want to do their own
// version gating. Empty for non-migration skills and for migration
// skills without declared bounds.
AppliesFrom string
AppliesTo string
// SkillForgeVersion is the forge version the skill content ships with —
// i.e. the version of the forge module/binary serving this listing.
// Empty for project/user-scope skills (their content is user-owned and
// not tied to a forge release).
SkillForgeVersion string
// ProjectForgeVersion is the forge_version pinned in the project's
// forge.yaml ("" when no project root was given, no pin exists, or the
// config could not be read).
ProjectForgeVersion string
// VersionSkew is true when SkillForgeVersion and ProjectForgeVersion
// are both known, comparable release versions and differ — i.e. the
// skill content served here comes from a different forge version than
// the one the project was generated with.
VersionSkew bool
}
SkillMetaPublic is the cross-package view of a skill returned from ListSkillsAt. Only metadata is included; bodies are fetched separately via ResolveSkillContentAt to keep enumeration cheap.
func ListSkillsAt ¶
func ListSkillsAt(projectRoot string) ([]SkillMetaPublic, error)
ListSkillsAt is the exported wrapper around [listSkillsAt], intended for consumers in sibling packages (e.g. forge/cli's public shim). It hides the unexported skillMeta type behind a stable struct.
Migration skills (relevance: migration) are excluded — this is the DEFAULT listing surface. Use ListSkillsAtWithOptions with IncludeMigrations to opt them in.
func ListSkillsAtWithOptions ¶
func ListSkillsAtWithOptions(projectRoot string, opts SkillListOptions) ([]SkillMetaPublic, error)
ListSkillsAtWithOptions is ListSkillsAt with explicit listing options. Additive surface — ListSkillsAt's signature is frozen for reliant.
type SkillRelevance ¶
type SkillRelevance string
SkillRelevance classifies WHEN a skill is worth surfacing, read from the YAML frontmatter `relevance:` field. It is orthogonal to SkillEmit (which audience) — relevance says "is this skill applicable to the steady-state of a project, or only during a specific transition?".
An empty value means "always relevant" — the default for every skill that predates the field.
Why relevance-class gating instead of version-range gating: forge binaries very commonly run as dev builds or Go pseudo-versions (see [isForgeVersionSkew], which treats those as non-comparable), so a listing-time version comparison would silently degrade to "include everything" for most real installs. Proper per-project version-range + detection-script gating for migration skills already exists in `forge upgrade list` (applies-from/applies-to frontmatter, upgrade_migrations.go); listings only need the coarse class. The applies-from/applies-to bounds are still parsed and exposed on the metadata so consumers (e.g. reliant) can make their own call.
const ( // SkillRelevanceMigration — one-time upgrade-transition playbooks // (skills/forge/migrations/*). Noise for any project that is already // past (or before) the transition, so DEFAULT listings and the // project-skill regeneration exclude them. They remain loadable by // exact path (`forge skill load migrations/...`) and are surfaced // with proper version-range gating by `forge upgrade list`. SkillRelevanceMigration SkillRelevance = "migration" )
type SkillScope ¶
type SkillScope string
SkillScope identifies where a skill was discovered from.
const ( // SkillScopeForge is a skill bundled with the forge binary (templates/project/skills). SkillScopeForge SkillScope = "forge" // SkillScopeProject is a skill discovered under <project_root>/.forge/skills/. SkillScopeProject SkillScope = "project" // SkillScopeUser is a skill discovered under ~/.forge/skills/. SkillScopeUser SkillScope = "user" )
func ResolveSkillContentAt ¶
func ResolveSkillContentAt(projectRoot, skillPath string) ([]byte, SkillScope, error)
ResolveSkillContentAt is the exported wrapper around [resolveSkillContentAt], with one addition for out-of-process consumers: when the skill is forge-shipped and the running forge version differs from the project's pinned forge_version, a one-line advisory is prepended to the body (after the YAML frontmatter) so the reader knows the guidance may not match the project's generated code.
type SkillWriteStyle ¶
type SkillWriteStyle string
SkillWriteStyle controls the on-disk layout produced by `forge skill write`.
const ( // SkillWriteStyleForge mirrors forge's own layout: <out>/<skill>/SKILL.md. SkillWriteStyleForge SkillWriteStyle = "forge" // SkillWriteStyleClaude is Claude Code's `.claude/skills/` layout — // same on-disk shape as forge (<out>/<skill>/SKILL.md), but always // includes YAML frontmatter so Claude can discover/route the skill. SkillWriteStyleClaude SkillWriteStyle = "claude" // SkillWriteStyleMD is a flat layout: <out>/<skill>.md, no per-skill dir. SkillWriteStyleMD SkillWriteStyle = "md" )
Source Files
¶
- accept_fork.go
- api.go
- audit_external_builds.go
- audit_ingress_cli.go
- audit_prereqs_cli.go
- build.go
- build_external.go
- build_generate.go
- build_guard.go
- build_state.go
- ci.go
- cluster_phase.go
- cluster_registry.go
- config.go
- db.go
- delete.go
- deploy.go
- deploy_dispatch.go
- deploy_helm.go
- deploy_namespace_check.go
- dev.go
- dev_cluster.go
- dev_cluster_ingress.go
- dev_cluster_mkcert.go
- dev_info.go
- dev_instances.go
- dev_logs.go
- dev_pkg_replace.go
- dev_status.go
- dev_urls.go
- devstack.go
- devstack_activate.go
- disown.go
- docs.go
- doctor.go
- doctor_dockerproxy.go
- doctor_external_builds.go
- doctor_ingress.go
- doctor_parity.go
- doctor_pkgpin.go
- doctor_tools.go
- env_discovery.go
- feature_gate.go
- features.go
- forge_descriptor.go
- forge_version.go
- friction.go
- friction_disown.go
- generate.go
- generate_bootstrap.go
- generate_buf.go
- generate_ci.go
- generate_cleanup.go
- generate_config_check.go
- generate_dangling_check.go
- generate_drift_hints.go
- generate_explain.go
- generate_explain_drift.go
- generate_frontend_hooks.go
- generate_frontend_mocks.go
- generate_frontend_nav.go
- generate_frontend_pages.go
- generate_helpers.go
- generate_legacy_migrate.go
- generate_middleware.go
- generate_openapi.go
- generate_orm.go
- generate_pipeline.go
- generate_pkg_compat.go
- generate_plan.go
- generate_rename_check.go
- generate_retire_disowns.go
- generate_serve.go
- generate_services.go
- generate_skills.go
- generate_stale_scaffold.go
- generate_tier1_scope.go
- generate_tier_migrate.go
- generate_tools.go
- generate_validate.go
- generate_watch.go
- githooks.go
- graph.go
- groups.go
- help_dev.go
- image_tag.go
- introspect.go
- kcl_render.go
- kubeconfig_secret.go
- license.go
- lockfile.go
- map.go
- map_helpers.go
- mcp.go
- memory.go
- migrate.go
- migrate_import.go
- new.go
- new_env.go
- package.go
- procgroup_unix.go
- procinspect_linux.go
- promote.go
- protoc_gen_orm.go
- reconcile.go
- release.go
- root.go
- run.go
- secrets_cmd.go
- secrets_wiring.go
- skill.go
- skills.go
- smoke.go
- smoke_classify.go
- smoke_dev.go
- smoke_flow.go
- test.go
- test_helpers.go
- test_migrate_tdd.go
- tools.go
- up.go
- up_reclaim.go
- upgrade.go
- upgrade_codemod.go
- upgrade_migrations.go
- upgrade_v0_1_to_v0_2.go
Directories
¶
| Path | Synopsis |
|---|---|
|
Package add holds the `forge add` command group — the verbs that scaffold a new component (service / worker / operator / binary / frontend / webhook / package / adapter / library / handler-file / rpc / entity / crd / scenario) into an existing forge project.
|
Package add holds the `forge add` command group — the verbs that scaffold a new component (service / worker / operator / binary / frontend / webhook / package / adapter / library / handler-file / rpc / entity / crd / scenario) into an existing forge project. |
|
Package audit holds the `forge audit` command group — a comprehensive snapshot of project state designed to orient an LLM (or human) without forcing them to grep ten different directories.
|
Package audit holds the `forge audit` command group — a comprehensive snapshot of project state designed to orient an LLM (or human) without forcing them to grep ten different directories. |
|
Package audittype holds the small, neutral value types shared by the `forge audit` command group (internal/cli/audit) and the internal/cli code that contributes audit categories it cannot compute without package-cli internals (the KCL-entity-typed ingress / external-builds categories, and friction.go's auditFriction).
|
Package audittype holds the small, neutral value types shared by the `forge audit` command group (internal/cli/audit) and the internal/cli code that contributes audit categories it cannot compute without package-cli internals (the KCL-entity-typed ingress / external-builds categories, and friction.go's auditFriction). |
|
Package backlog holds the `forge backlog` command group — list / add / close / open / migrate over the structured FORGE_BACKLOG.md.
|
Package backlog holds the `forge backlog` command group — list / add / close / open / migrate over the structured FORGE_BACKLOG.md. |
|
Package cmdutil holds cross-cutting helpers shared by forge's own CLI across MORE THAN ONE command group (internal/cli and its dir-nested subpackages).
|
Package cmdutil holds cross-cutting helpers shared by forge's own CLI across MORE THAN ONE command group (internal/cli and its dir-nested subpackages). |
|
Package component holds the `forge component` command group — list, search, and install UI components from forge's built-in component library.
|
Package component holds the `forge component` command group — list, search, and install UI components from forge's built-in component library. |
|
Package debug holds the `forge debug` command group — a Delve-backed interactive debugger driver (start / break / continue / eval / ...).
|
Package debug holds the `forge debug` command group — a Delve-backed interactive debugger driver (start / break / continue / eval / ...). |
|
Package factory carries the shared dependency set ("the factory") threaded through forge's own CLI command tree, plus the command REGISTRY that lets dir-nested command-group subpackages (internal/cli/add, internal/cli/lint, ...) attach to the root without a group↔root import cycle.
|
Package factory carries the shared dependency set ("the factory") threaded through forge's own CLI command tree, plus the command REGISTRY that lets dir-nested command-group subpackages (internal/cli/add, internal/cli/lint, ...) attach to the root without a group↔root import cycle. |
|
Package lint holds the `forge lint` command group — the project linter pipeline (golangci / buf / frontend / forge-convention / scaffold / migration-safety / wire-coverage / authz-completeness …) plus the targeted single-rule flags and the --json aggregator.
|
Package lint holds the `forge lint` command group — the project linter pipeline (golangci / buf / frontend / forge-convention / scaffold / migration-safety / wire-coverage / authz-completeness …) plus the targeted single-rule flags and the --json aggregator. |
|
Package pack holds the `forge pack` command group — manage installable packs (list / install / remove / info).
|
Package pack holds the `forge pack` command group — manage installable packs (list / install / remove / info). |