devserver

package
v0.35.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: Apache-2.0 Imports: 44 Imported by: 0

Documentation

Overview

Package devserver — Stripe mock backend.

This file implements just enough of Stripe's HTTP API to round-trip cleanly through the official stripe-go SDK. Apps point stripe-go at the dev proxy via stripe.SetBackend(...) with BackendConfig.URL = "<HAMR_DEV_URL>/__hamr/stripe", and stripe-go appends paths like /v1/checkout/sessions to that base.

The mock is dev-only and has no production safeguards beyond the gating done by hamr.toml [dev.stripe].

Index

Constants

View Source
const MCPHandshakeFile = ".hamr/dev.json"

MCPHandshakeFile is the per-project runtime descriptor the `hamr mcp` bridge reads to find and authenticate to this dev server. Mode 0600, gitignored. Path is relative to the project root.

View Source
const PrefsFileName = ".pref.hamr.toml"

PrefsFileName is the per-developer override read from the same directory as the main config. Gitignored by the scaffold: it holds preferences a developer wants locally without imposing them on the team.

Variables

View Source
var ErrConfigReload = errors.New("config changed, reloading")

ErrConfigReload is returned by Run when the config file changes. The caller should reload the config and call Run again.

Functions

func CheckLatestVersion

func CheckLatestVersion(ctx context.Context, currentVersion string, onResult func(latest string))

CheckLatestVersion checks GitHub for the latest hamr release and calls onResult with the latest version string if it is newer than current. The check runs in a goroutine and is best-effort — network errors are silently ignored.

func ComposeArgs

func ComposeArgs(dc *DockerCompose) []string

ComposeArgs returns the `docker` arguments hamr itself uses for a compose entry — project directory, base file, and the generated port-walk override when one exists. Exported so `hamr compose` can hand external callers the same merged config the dev server is running, instead of them merging the base file alone and reconciling the stack back onto un-walked ports.

Paths are relative to the project root, so the caller must run docker from there.

func HamrDevTag

func HamrDevTag() string

HamrDevTag returns the colored [hamr dev] tag string for use outside the logger.

func ListenAndServeProxy

func ListenAndServeProxy(addr string, handler http.Handler) (*http.Server, net.Listener, error)

ListenAndServeProxy starts the proxy server. It blocks until the context is cancelled or an error occurs. Kept for tests and external callers that don't need the +1-on-busy port walking — the dev runner uses listenWalk + serveProxy directly so it can react to EADDRINUSE before constructing the handler.

func MCPAreaNames

func MCPAreaNames() []string

MCPAreaNames returns every configurable [dev.mcp.access] area in a stable order. Exported for the `hamr setup` picker, which needs the canonical list without duplicating it.

func MakefileTargetsFromPath

func MakefileTargetsFromPath(path string) ([]string, error)

MakefileTargetsFromPath reads the Makefile at path and returns its declared target names in the order they appear. Pattern rules (containing '%'), variables (lines with ':=' / '+=' / '?='), comments, and recipe lines (starting with a tab) are ignored. The function does not parse includes or expand variables — the dev TUI's run overlay just needs the human-visible target list, not full make semantics.

Returns ([]string{}, nil) when the file does not exist so callers can gate UI on a non-error empty list.

func NewProxyHandler

func NewProxyHandler(target string, broker *SSEBroker, errorState *ErrorState, logBuf *LogBuffer, actions *DevActions, mailMock *MailMock, smsMock *SMSMock, stripeMock *StripeMock, console *ConsoleSink, gateway *mcpGateway, requestLog *RequestLog, injectReload bool) http.Handler

NewProxyHandler creates an HTTP handler that reverse-proxies to the target address, optionally injecting the live reload script into HTML responses. The SSE broker handler is mounted at /__hamr/reload. If errorState is non-nil, HTML requests are intercepted with an error page when there are active build errors. If mailMock is non-nil, the mail inbox UI and ingest endpoint are mounted under /__hamr/mail. If smsMock is non-nil, the SMS equivalents are mounted under /__hamr/sms. If console is non-nil, the browser console transport is mounted as a WebSocket at /__hamr/console; the injected reload script connects to it and pipes window.console.* + uncaught errors back into the dev TUI/log.

func PrefsPathFor

func PrefsPathFor(configPath string) string

PrefsPathFor returns the override path that pairs with the given config file — a sibling of it, so `hamr dev --config /elsewhere/hamr.toml` reads /elsewhere/.pref.hamr.toml.

func ResolveEnvRewrites

func ResolveEnvRewrites(dir string) ([]string, error)

ResolveEnvRewrites loads .hamr/walks.json and .env from dir, applies the active port-walk rewrite rules, and returns the rewritten KEY=VALUE pairs the spawned-children injection would emit. Empty result (nil, nil) when nothing walked or no .env present — consumers can use the result unconditionally; an empty slice makes their downstream a no-op.

This is the canonical entry point for callers outside the package (cmd/env, etc.). Match rules and limitations are documented on the per-rule helpers.

func RewriteValueForWalks

func RewriteValueForWalks(dir, value string) string

RewriteValueForWalks applies the active port-walk rewrites to a single value (typically one read out of .env by hamr sync). Returns the value unchanged when no walks file is present or when nothing in the value matches a walked port. Errors loading walks.json are swallowed: a malformed file shouldn't break a CLI invocation that has a perfectly good fallback in the literal value the caller already has.

func RunMockServe

func RunMockServe(ctx context.Context, logger *slog.Logger) error

RunMockServe stands up the selected mocks and serves until ctx is cancelled. Selection and all configuration come from environment variables.

func WaitForConfigChangeOrQuit

func WaitForConfigChangeOrQuit(ctx context.Context, path string, hotkeys <-chan HotkeyAction) error

WaitForConfigChangeOrQuit blocks until the file at path is written or created, ctx is cancelled, or a HotkeyQuit comes in (in which case it returns context.Canceled). Non-quit hotkeys are silently consumed. A nil hotkeys channel is safe (blocks forever).

Types

type Config

type Config struct {
	Dev   DevConfig   `toml:"dev"`
	Proxy ProxyConfig `toml:"proxy"`

	// ProxyConfigured is true when [proxy] was explicitly present in the TOML.
	// When false, the proxy is not started and no defaults are applied.
	ProxyConfigured bool `toml:"-"`
}

Config is the top-level hamr.toml configuration.

func LoadConfig

func LoadConfig(path string) (*Config, error)

LoadConfig reads and parses a hamr.toml file, merges the per-developer .pref.hamr.toml override over it if present, applies defaults, and validates.

func LoadConfigNoPrefs

func LoadConfigNoPrefs(path string) (*Config, error)

LoadConfigNoPrefs is LoadConfig without the .pref.hamr.toml merge. Use it anywhere the loaded values are written back to hamr.toml — merging first would promote a developer's gitignored local preference into the team's committed config.

type ConsoleFrame

type ConsoleFrame struct {
	// Level is one of: "log", "info", "debug", "warn", "error".
	// Internal categories the JS may send for non-console events:
	// "rejection" (unhandled promise), "resource" (load failure),
	// "csp" (CSP violation). Only "warn" and "error" get a colored
	// uppercase level label in the rendered line; every other value
	// (including the internal categories) renders without a label so
	// the line stays scannable. This matches the backend slog handler's
	// convention.
	Level string `json:"level"`

	// Msg is the rendered text (args joined client-side, objects already
	// JSON-stringified, capped per-arg by the JS serializer).
	Msg string `json:"msg"`

	// Src is an optional source location, used only for uncaught errors
	// where the file:line:col is load-bearing. Plain console.* calls
	// leave it empty.
	Src string `json:"src,omitempty"`
}

ConsoleFrame is one log entry sent up by the browser. The field set is kept narrow on purpose: anything more (timestamps, URLs, multi-tab IDs) goes through a different conversation.

type ConsoleSink

type ConsoleSink struct {
	// contains filtered or unexported fields
}

ConsoleSink is the dev-server side of the browser-console transport. It owns the WS endpoint and a single io.Writer (the same multiwriter the dev slog handler uses) so frames land in TUI tab 0 and the rolling dev_logs.txt file alongside backend events, in arrival order.

func NewConsoleSink

func NewConsoleSink(w io.Writer, filterHamr bool) *ConsoleSink

NewConsoleSink wires the sink to the same writer as the dev logger. Pass filterHamr=true to drop frames whose msg contains "[hamr]" (i.e. hamr's own reload-script chatter); default is to show everything.

func (*ConsoleSink) Handler

func (c *ConsoleSink) Handler() http.Handler

Handler returns the WS upgrade handler. Mount at /__hamr/console. Each frame on the wire is JSON; the wire format accepts either a single ConsoleFrame object or an array (the JS client batches small bursts to keep frame count down). Unparseable payloads are dropped silently — dev-only, not worth surfacing to the user.

Origin gating is the coder/websocket default: Origin must equal Host. In dev that's always true (the JS is injected by the same proxy that serves the WS). External callers hitting the endpoint cross-origin will be rejected at upgrade.

func (*ConsoleSink) Snapshot

func (c *ConsoleSink) Snapshot(level, contains string, tail int) []consoleLine

Snapshot returns up to tail recent frames matching the level (exact, case- insensitive) and contains (substring on msg) filters, oldest first.

func (*ConsoleSink) Write

func (c *ConsoleSink) Write(f ConsoleFrame)

Write renders a single frame and emits it through the dev writer. Empty messages and (when filtering) hamr-prefixed messages are dropped. Exported so tests can drive formatting without standing up a WS server.

type Daemon

type Daemon struct {
	Name string   `toml:"name"`
	Cmd  string   `toml:"cmd"`
	Dir  string   `toml:"dir"`
	Env  []string `toml:"env"`
}

Daemon defines a long-running background process started once at launch.

type DevActions

type DevActions struct {
	// contains filtered or unexported fields
}

DevActions encapsulates API action handlers for the dev panel.

func (*DevActions) DockerComposes

func (a *DevActions) DockerComposes() []DockerCompose

DockerComposes returns the configured docker compose entries the runner is managing.

func (*DevActions) DockerWipe

func (a *DevActions) DockerWipe(dc *DockerCompose, service string)

DockerWipe triggers a "down -v + up -d" cycle for the given compose entry, removing volumes. When service is "" the whole entry is wiped; otherwise only that service. Runs synchronously on the calling goroutine — TUI callers should dispatch in a goroutine to keep the UI responsive.

func (*DevActions) ErrorState

func (a *DevActions) ErrorState() *ErrorState

ErrorState returns the underlying error state so non-HTTP consumers (the TUI runtime) can subscribe to error changes.

func (*DevActions) RebuildAll

func (a *DevActions) RebuildAll()

RebuildAll enqueues every watch rule onto the scheduler, which resolves topological order and dependency gating itself. Used by the hotkey system to trigger a full rebuild.

func (*DevActions) RegisterRoutes

func (a *DevActions) RegisterRoutes(mux *http.ServeMux)

RegisterRoutes registers the action API endpoints on the given mux.

type DevConfig

type DevConfig struct {
	Watch           []WatchRule     `toml:"watch"`
	Daemons         []Daemon        `toml:"daemon"`
	DockerCompose   []DockerCompose `toml:"docker_compose"`
	LogFile         string          `toml:"log_file"`
	LogFileMaxLines int             `toml:"log_file_max_lines"`
	ProxyListen     string          `toml:"proxy_listen"`
	ProxyTarget     string          `toml:"proxy_target"`
	InjectReload    *bool           `toml:"inject_reload"`
	Email           EmailConfig     `toml:"email"`
	SMS             SMSConfig       `toml:"sms"`
	Stripe          StripeConfig    `toml:"stripe"`
	MCP             MCPConfig       `toml:"mcp"`

	// DarkFilter sets the initial state of the dev-panel "Dark filter"
	// toggle: an invert(1) hue-rotate(180deg) CSS filter over the proxied
	// site so a light-mode app is bearable to work on. Default false. The
	// panel toggle flips it at runtime in the hamr dev process only — the
	// value is never written back to hamr.toml.
	DarkFilter bool `toml:"dark_filter"`

	// HamrConsoleCapture toggles the entire browser-console transport
	// (window.console.* + uncaught errors + unhandled rejections +
	// resource-load failures + CSP violations → /__hamr/console WS →
	// `[site:console]` lines in the dev TUI / log file). Default true
	// (nil pointer): on by default in fresh scaffolds. Set false to skip
	// mounting the WS endpoint and to tell the injected reload script
	// not to patch console or open a connection — zero overhead.
	HamrConsoleCapture *bool `toml:"hamr_console_capture"`

	// HamrConsoleFilter, when true, drops browser console frames whose
	// message contains "[hamr]" — i.e. logs emitted by hamr's own injected
	// reload script. Default false: show everything the browser sees,
	// including hamr's own chatter. Flip true if the per-save chatter
	// (`[hamr] live reload connected`, `[hamr] page swapped`, etc.) is
	// noisy enough to drown out app-side logs. No effect when
	// HamrConsoleCapture is false.
	HamrConsoleFilter bool `toml:"hamr_console_filter"`

	// PortWalk toggles the +1-on-busy walk for hamr-managed ports
	// (proxy.listen, proxy.target / spawned-app PORT, and docker-compose
	// host-port publishes). Default true: when a port is busy hamr walks +1
	// up to a small cap and logs a WARN per shift, so two `hamr dev`
	// instances on the same machine don't collide. Set false to disable
	// walking and fail fast on EADDRINUSE — useful when CI or external
	// tooling pins a specific port and would mis-target if hamr silently
	// shifted.
	PortWalk *bool `toml:"port_walk"`
}

DevConfig holds the [dev] table with watch rules and daemons.

func (DevConfig) HamrConsoleCaptureEnabled

func (c DevConfig) HamrConsoleCaptureEnabled() bool

HamrConsoleCaptureEnabled returns whether the browser-console transport is on. Defaults to true when the field is unset (nil) — opt-out, not opt-in.

func (DevConfig) PortWalkEnabled

func (c DevConfig) PortWalkEnabled() bool

PortWalkEnabled returns whether the +1-on-busy port walk is enabled. Defaults to true when the field is unset (nil) — opt-out, not opt-in.

type DockerCompose

type DockerCompose struct {
	Name        string   `toml:"name"`
	File        string   `toml:"file"`
	Services    []string `toml:"services"`
	KeepRunning bool     `toml:"keep_running"`
	WaitReady   bool     `toml:"wait_ready"`
	Env         []string `toml:"env"`
}

DockerCompose declares a docker compose file that hamr ensures is running.

type Duration

type Duration struct {
	time.Duration
}

Duration wraps time.Duration with TOML unmarshaling that accepts an integer (milliseconds) or a Go duration string like "200ms".

func (*Duration) UnmarshalTOML

func (d *Duration) UnmarshalTOML(data any) error

UnmarshalTOML implements the toml.Unmarshaler interface.

type EmailConfig

type EmailConfig struct {
	Enabled         bool   `toml:"enabled"`
	MaxMessages     int    `toml:"max_messages"`      // default 500
	MaxMessageBytes int64  `toml:"max_message_bytes"` // default 10MiB
	Persist         *bool  `toml:"persist"`           // default true
	PersistPath     string `toml:"persist_path"`      // default ".hamr/mail/inbox.mbox"
}

EmailConfig holds the [dev.email] table for the mail mock. When Enabled is true, hamr dev runs an email inbox at /__hamr/mail on the reverse proxy. Requires [proxy] to be configured.

Persistence defaults to on: the inbox is mirrored to an mbox file at PersistPath so it survives hamr dev restart. Set Persist=false for an ephemeral in-memory-only inbox.

func (EmailConfig) PersistEnabled

func (c EmailConfig) PersistEnabled() bool

PersistEnabled returns whether persistence is on. Defaults to true when the field is unset (nil).

func (EmailConfig) ResolvedPersistPath

func (c EmailConfig) ResolvedPersistPath() string

ResolvedPersistPath returns PersistPath with the default applied.

type ErrorState

type ErrorState struct {
	// contains filtered or unexported fields
}

ErrorState tracks active build/process errors in a thread-safe manner. The proxy reads it to decide whether to serve the error page; devserver writes it on build failure / success.

func NewErrorState

func NewErrorState() *ErrorState

NewErrorState creates an empty ErrorState.

func (*ErrorState) Clear

func (e *ErrorState) Clear(rule string)

Clear removes the error for the given rule.

func (*ErrorState) HasErrors

func (e *ErrorState) HasErrors() bool

HasErrors returns true if any rule has an active error.

func (*ErrorState) OnChange

func (e *ErrorState) OnChange(fn func())

OnChange registers a callback that fires after Set or Clear modifies state.

func (*ErrorState) RuleNames

func (e *ErrorState) RuleNames() []string

RuleNames returns the sorted names of rules with active errors.

func (*ErrorState) Set

func (e *ErrorState) Set(rule, output string)

Set records a build/process error for the given rule.

func (*ErrorState) Snapshot

func (e *ErrorState) Snapshot() map[string]string

Snapshot returns a copy of the current errors map.

type FileEvent

type FileEvent struct {
	Rule *WatchRule
	Path string
	Time time.Time
}

FileEvent is emitted when a watched file changes and matches a rule.

type Graph

type Graph struct {
	// contains filtered or unexported fields
}

Graph tracks dependency relationships and coordinates execution order between watch rules using channels for signaling.

func NewGraph

func NewGraph(rules []WatchRule) *Graph

NewGraph builds a dependency graph from the given watch rules. It assumes rules have already been validated (no cycles, no unknown deps).

func (*Graph) MarkDone

func (g *Graph) MarkDone(name string)

MarkDone closes the named rule's done channel, unblocking all dependees.

func (*Graph) MarkRunning

func (g *Graph) MarkRunning(name string)

MarkRunning resets the named rule's done channel so dependees will block.

func (*Graph) TopologicalOrder

func (g *Graph) TopologicalOrder() []string

TopologicalOrder returns rule names in an order that respects dependencies (dependencies come before dependees).

func (*Graph) WaitForDeps

func (g *Graph) WaitForDeps(ctx context.Context, name string) error

WaitForDeps blocks until all dependencies of the named rule have completed. Returns an error if the context is cancelled.

type HotkeyAction

type HotkeyAction int

HotkeyAction represents a user-triggered hotkey action.

const (
	HotkeyRebuild HotkeyAction = iota
	HotkeyOpenBrowser
	HotkeyQuit
	HotkeyMCPToggle
)

type HotkeySource

type HotkeySource interface {
	Actions() <-chan HotkeyAction
}

HotkeySource emits hotkey actions for the dev runner to consume. The TUI implements this with a bubbletea-backed adapter; the runner reads from Actions() in its event loop. A nil channel means no source is attached and the loop should never fire on it.

type LogBuffer

type LogBuffer struct {
	// contains filtered or unexported fields
}

LogBuffer is a thread-safe ring buffer that keeps the last N log lines.

func NewLogBuffer

func NewLogBuffer(max int) *LogBuffer

NewLogBuffer creates a new LogBuffer capped at max lines.

func (*LogBuffer) Append

func (lb *LogBuffer) Append(line LogLine)

Append adds a line to the buffer, trimming the oldest if over capacity. A zero Time is stamped now, so every path (logWriter, direct appends like the make.run completion marker) carries a timestamp for logs.read.

func (*LogBuffer) Lines

func (lb *LogBuffer) Lines() []LogLine

Lines returns a copy of all buffered log lines.

type LogLine

type LogLine struct {
	Rule  string    `json:"rule"`
	Text  string    `json:"text"`
	Color string    `json:"color,omitempty"`
	Time  time.Time `json:"time"`
}

LogLine is a single line of process output tagged with its rule name.

type MCPConfig

type MCPConfig struct {
	// Enabled is the initial runtime state at launch. The TUI kill-switch can
	// flip the live gateway without rewriting this. Default false.
	Enabled bool `toml:"enabled"`

	// Access maps a functional area (dev, logs, docker, mail, sms, build, stripe) to
	// a level ("read", "write", or "deny"). "write" implies "read". Areas absent
	// from the map are denied. With no table at all, zero tools are exposed.
	Access map[string]string `toml:"access"`

	// MakeTargets constrains make.run to a named subset. Empty = every Makefile
	// target is allowed (the permissive default).
	MakeTargets []string `toml:"make_targets"`

	// MakeWait bounds how long make.run blocks before returning a "still
	// running, poll logs" result. Default 20s when unset.
	MakeWait Duration `toml:"make_wait"`

	// LogFile is the MCP audit log path. Default ".hamr/mcp_logs.txt"; set to
	// "none" to disable the audit log.
	LogFile string `toml:"log_file"`
}

MCPConfig holds the [dev.mcp] table — the gateway that lets an AI agent drive hamr dev through the `hamr mcp` stdio bridge. Off by default; opt-in.

The bridge connects over the existing localhost /__hamr/* HTTP API, authenticated by a per-run token (see the gateway). Permissions are granted per functional area at read/write/deny granularity via Access; an area not granted exposes none of its tools.

func (MCPConfig) EnabledTools

func (c MCPConfig) EnabledTools() map[string]bool

EnabledTools returns the set of tool names the Access map exposes. Unknown areas/levels are ignored here (validate() rejects them at load time).

func (MCPConfig) MakeTargetAllowed

func (c MCPConfig) MakeTargetAllowed(target string) bool

MakeTargetAllowed reports whether make.run may run the given target. Empty MakeTargets means every target is allowed.

func (MCPConfig) ResolvedLogFile

func (c MCPConfig) ResolvedLogFile() string

ResolvedLogFile returns the audit-log path with the default applied, or "" when the audit log is disabled ("none").

func (MCPConfig) ResolvedMakeWait

func (c MCPConfig) ResolvedMakeWait() time.Duration

ResolvedMakeWait returns the make.run bounded-wait duration, defaulting to 20s when unset or non-positive.

func (MCPConfig) ToolAllowed

func (c MCPConfig) ToolAllowed(tool string) bool

ToolAllowed reports whether the named tool is exposed by the current Access map. The gateway uses this to enforce permissions per call.

type MCPHandshake

type MCPHandshake struct {
	ProxyURL string `json:"proxyURL"`
	Token    string `json:"token"`
}

MCPHandshake is the JSON written to .hamr/dev.json by an enabled gateway and read by the `hamr mcp` bridge — the single source of truth for the wire format the two share.

func ReadMCPHandshake

func ReadMCPHandshake(projectRoot string) (MCPHandshake, error)

ReadMCPHandshake loads the handshake descriptor from projectRoot. Returns a clear error when the file is absent (dev server not running / MCP disabled).

type MailMock

type MailMock struct {
	// contains filtered or unexported fields
}

MailMock is a dev-only email inbox. Messages arrive via /__hamr/mail/ingest (POST JSON), are stored in a ring buffer, and are viewable at /__hamr/mail on the reverse proxy. If persistPath is set, the inbox is mirrored to an mbox file on disk so it survives hamr dev restart.

func NewMailMock

func NewMailMock(opts MailMockOptions) *MailMock

NewMailMock returns a MailMock with the given options. If PersistPath is non-empty, any existing inbox at that path is loaded and subsequent changes are mirrored there. Load failures are reported via OnPersistError (if set) but never fatal — the in-memory inbox starts empty.

func (*MailMock) Clear

func (m *MailMock) Clear()

Clear empties the inbox.

func (*MailMock) Delete

func (m *MailMock) Delete(id string) bool

Delete removes a single message by id. Returns true if it existed.

func (*MailMock) Get

func (m *MailMock) Get(id string) *mailMessage

Get returns a deep copy of the message with the given id, or nil if not found. The copy decouples callers from concurrent mutation.

func (*MailMock) List

func (m *MailMock) List() []*mailMessage

List returns a newest-first snapshot of the inbox. Messages are deep-copied so callers can read fields without racing concurrent mutators (SetStatus).

func (*MailMock) RegisterIngestRoutes

func (m *MailMock) RegisterIngestRoutes(mux *http.ServeMux)

RegisterIngestRoutes mounts the SMTP capture sink. handleIngest is server-to-server (no browser Origin) — intentionally NOT origin-guarded; see guardUnsafe.

func (*MailMock) RegisterRoutes

func (m *MailMock) RegisterRoutes(mux *http.ServeMux)

RegisterRoutes mounts both the UI and the ingest endpoint on mux. Do not register twice on the same mux — http.ServeMux panics on duplicate patterns.

func (*MailMock) RegisterUIRoutes

func (m *MailMock) RegisterUIRoutes(mux *http.ServeMux)

RegisterUIRoutes mounts the human-facing inbox UI. Split from the ingest sink so the two can live on separate listeners (see `hamr mock-serve`).

func (*MailMock) SetStatus

func (m *MailMock) SetStatus(id, status, note string) bool

SetStatus marks a stored message as having a particular outcome. Used by the UI to simulate post-hoc bounce/delay on an already-captured message. Allowed values: "failed", "delayed" ("delivered" is the implicit default set at ingest and cannot be re-applied here). Unknown values are rejected.

Persistence: rewrites the whole mbox file (status is in the headers).

type MailMockOptions

type MailMockOptions struct {
	MaxMessages     int         // default 500
	MaxMessageBytes int64       // default 10 MiB
	PersistPath     string      // "" disables persistence
	OnPersistError  func(error) // invoked on disk write/read errors; nil is silent
}

MailMockOptions configures a MailMock at construction.

type MockProvider

type MockProvider struct {
	Name  string
	Build func(logger *slog.Logger) (*MountedMock, error)
}

MockProvider registers one mock. Each provider reads its own HAMR_* env vars in Build. Adding a new mock is one entry in mockProviders.

type MountedMock

type MountedMock struct {
	RegisterAPI func(*http.ServeMux) // app-facing (stripe /v1, mail/sms ingest)
	RegisterUI  func(*http.ServeMux) // human-facing dashboards
}

MountedMock is what a provider returns: the route registrations for each surface. Either may be nil if a mock has no routes on that surface.

type Option

type Option func(*Runner)

Option configures a Runner.

func WithActionsHook

func WithActionsHook(fn func(*DevActions)) Option

WithActionsHook registers a callback that fires once Run has constructed its DevActions object. The TUI uses this to capture a reference for dispatching actions (e.g. docker wipe) that aren't expressible through the scalar HotkeyAction enum. The hook fires on the runner goroutine; copy the pointer and return — do not block.

func WithConfigPath

func WithConfigPath(path string) Option

WithConfigPath sets the config file path so the runner can watch it for changes.

func WithDockerLogSinks

func WithDockerLogSinks(sinks map[string]io.Writer) Option

WithDockerLogSinks subscribes one writer per `[[dev.docker_compose]]` entry to that stack's `docker compose logs -f` output. Keys in the map are the same `name` field hamr.toml uses; entries without a writer are skipped (no follower spawned).

The runner manages follower lifetime: started once an entry has been brought up, restarted automatically if the follower exits early (typically because `docker compose down -v` from a wipe killed it), stopped on shutdown via the runner ctx.

func WithHotkeys

func WithHotkeys(h HotkeySource) Option

WithHotkeys wires the bubbletea-backed HotkeySource that feeds q / r / o into Run's event loop. The TUI runtime owns the source's lifecycle.

func WithLogWriter

func WithLogWriter(w io.Writer) Option

WithLogWriter overrides the base writer used by the runner's default slog handler (defaults to os.Stderr). The file logger fan-out, when enabled, remains on top. TUI mode wires this to a viewport-backed sink so the runner's own log lines render inside the TUI instead of corrupting the frame.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the logger for the runner.

func WithMCPLogHook

func WithMCPLogHook(fn func(line string)) Option

WithMCPLogHook registers a callback that receives a one-line summary of every MCP request the gateway handles, so the TUI can render a dedicated MCP tab. Fires on the gateway's request goroutine; do not block.

func WithMCPStatusHook

func WithMCPStatusHook(fn func(enabled bool, tools int)) Option

WithMCPStatusHook registers a callback that receives the MCP gateway's state (enabled, exposed-tool count) at startup and on every M-toggle, so the TUI can render its indicator. Fires on the runner goroutine; do not block.

func WithNoProxy

func WithNoProxy(v bool) Option

WithNoProxy disables the reverse proxy.

func WithProcessOutput

func WithProcessOutput(stdout, stderr io.Writer) Option

WithProcessOutput redirects child-process stdout/stderr away from the terminal into the given writers. Internally calls SetOutputSinks on the ProcessManager once it's constructed inside Run. Used by the TUI runtime.

func WithProxyURLHook

func WithProxyURLHook(fn func(string)) Option

WithProxyURLHook registers a callback that fires once the reverse proxy has bound (after any +1-on-busy port walking) so the caller can publish the actual reachable URL to its UI surface. The TUI runtime uses this to push the URL into a bubbletea message. The hook fires on the runner goroutine; copy the string and return — do not block.

func WithVerbose

func WithVerbose(v bool) Option

WithVerbose enables verbose logging.

type ProcessManager

type ProcessManager struct {
	OnProcessExit func(rule string, err error, output string)
	// contains filtered or unexported fields
}

ProcessManager handles running one-shot commands and long-running processes.

func NewProcessManager

func NewProcessManager(logger *slog.Logger) *ProcessManager

NewProcessManager creates a new process manager.

func (*ProcessManager) ClearCallbacks

func (pm *ProcessManager) ClearCallbacks()

ClearCallbacks disables all process exit callbacks. Used during shutdown to prevent spurious build_error events.

func (*ProcessManager) RunCommand

func (pm *ProcessManager) RunCommand(ctx context.Context, rule *WatchRule) (string, error)

RunCommand runs a one-shot command to completion. Stdout and stderr are streamed through the logger, and the captured tail output is returned regardless of exit status (alongside the error on failure) — callers that only care about output on error can ignore it on success, while one-shot tools (e.g. the MCP make.run) can surface it.

func (*ProcessManager) SetFileLog

func (pm *ProcessManager) SetFileLog(w io.Writer)

SetFileLog enables writing prefixed process output to a rolling file logger.

func (*ProcessManager) SetInjectedEnv

func (pm *ProcessManager) SetInjectedEnv(env []string)

SetInjectedEnv configures vars hamr will inject into every spawned rule process. Rule-level Env still overrides on key conflict (last-wins via buildEnv). Used to feed scaffolded apps the mock URLs hamr is hosting (e.g. HAMR_STRIPE_MOCK_URL=http://localhost:3000) so the scaffold's main.go doesn't need to hardcode those URLs and they automatically track hamr.toml's [proxy].listen.

func (*ProcessManager) SetLogOutput

func (pm *ProcessManager) SetLogOutput(buf *LogBuffer, broker *SSEBroker)

SetLogOutput enables streaming process output to a LogBuffer and SSE broker.

func (*ProcessManager) SetOutputSinks

func (pm *ProcessManager) SetOutputSinks(stdout, stderr io.Writer)

SetOutputSinks redirects subprocess stdout/stderr away from the terminal (os.Stdout / os.Stderr) into the given writers. The file logger fan-out configured via SetFileLog is preserved on top. Pass nil writers to clear.

Used by the TUI runtime so child output flows into a bubbletea-managed viewport instead of corrupting the rendered frame.

func (*ProcessManager) StartProcess

func (pm *ProcessManager) StartProcess(ctx context.Context, rule *WatchRule) error

StartProcess starts a long-running process, killing any previous instance. The process is tracked and can be stopped via StopAll.

func (*ProcessManager) StopAll

func (pm *ProcessManager) StopAll()

StopAll gracefully stops all tracked processes.

type ProxyConfig

type ProxyConfig struct {
	Listen       string `toml:"listen"`
	Target       string `toml:"target"`
	InjectReload *bool  `toml:"inject_reload"`
}

ProxyConfig holds the [proxy] table.

type ReloadScope

type ReloadScope string

ReloadScope controls what kind of browser reload a rule triggers. Values: "full", "css", "none", or a boolean (true="full", false="none").

const (
	ReloadFull ReloadScope = "full"
	ReloadCSS  ReloadScope = "css"
	ReloadNone ReloadScope = "none"
)

func (*ReloadScope) UnmarshalTOML

func (r *ReloadScope) UnmarshalTOML(data any) error

UnmarshalTOML implements the toml.Unmarshaler interface.

type RequestLog

type RequestLog struct {
	// contains filtered or unexported fields
}

RequestLog is a thread-safe ring buffer of recent proxy requests, feeding the MCP http.read tool. It captures every request the proxy serves — proxied app traffic, static assets, the /__hamr/* endpoints, and the SSE/WS streams — which is the view the app's own access log can't give (the app never sees proxy-handled routes, and skips /static).

func NewRequestLog

func NewRequestLog(max int) *RequestLog

NewRequestLog creates a request log capped at max entries.

func (*RequestLog) Record

func (rl *RequestLog) Record(e RequestLogEntry)

Record appends a completed entry, trimming the oldest once over capacity.

func (*RequestLog) Snapshot

func (rl *RequestLog) Snapshot() []RequestLogEntry

Snapshot returns a copy of all buffered entries (oldest first).

type RequestLogEntry

type RequestLogEntry struct {
	Time       time.Time `json:"time"`
	Method     string    `json:"method"`
	Path       string    `json:"path"`
	Status     int       `json:"status"`
	DurationMs int64     `json:"durationMs"`
}

RequestLogEntry is one observed HTTP request through the dev proxy.

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner is the top-level dev server orchestrator.

func NewRunner

func NewRunner(cfg *Config, opts ...Option) *Runner

NewRunner creates a new Runner with the given config and options.

func (*Runner) Run

func (r *Runner) Run(ctx context.Context) error

Run starts the dev server and blocks until ctx is cancelled.

type SMSConfig

type SMSConfig struct {
	Enabled     bool   `toml:"enabled"`
	MaxMessages int    `toml:"max_messages"` // default 500
	Persist     *bool  `toml:"persist"`      // default true
	PersistPath string `toml:"persist_path"` // default ".hamr/sms/inbox.jsonl"
}

SMSConfig holds the [dev.sms] table for the SMS mock. When Enabled is true, hamr dev runs an SMS inbox at /__hamr/sms on the reverse proxy. Requires [proxy] to be configured.

Persistence defaults to on: the inbox is mirrored to a JSONL file at PersistPath so it survives hamr dev restart. Set Persist=false for an ephemeral in-memory-only inbox.

func (SMSConfig) PersistEnabled

func (c SMSConfig) PersistEnabled() bool

PersistEnabled returns whether persistence is on. Defaults to true when the field is unset (nil) — matches the email mock's behaviour.

func (SMSConfig) ResolvedPersistPath

func (c SMSConfig) ResolvedPersistPath() string

ResolvedPersistPath returns PersistPath with the default applied.

type SMSMock

type SMSMock struct {
	// contains filtered or unexported fields
}

SMSMock is a dev-only SMS inbox. Messages arrive via /__hamr/sms/ingest (POST JSON), are stored in a ring buffer, and are viewable at /__hamr/sms on the reverse proxy. If persistPath is set, the inbox is mirrored to a JSONL file on disk so it survives hamr dev restart.

func NewSMSMock

func NewSMSMock(opts SMSMockOptions) *SMSMock

NewSMSMock returns an SMSMock with the given options. If PersistPath is non-empty, any existing inbox at that path is loaded and subsequent changes are mirrored there. Load failures are reported via OnPersistError (if set) but never fatal — the in-memory inbox starts empty.

func (*SMSMock) Clear

func (m *SMSMock) Clear()

Clear empties the inbox.

func (*SMSMock) Delete

func (m *SMSMock) Delete(id string) bool

Delete removes a single message by id. Returns true if it existed.

func (*SMSMock) Get

func (m *SMSMock) Get(id string) *smsMessage

Get returns a copy of the message with the given id, or nil if not found.

func (*SMSMock) List

func (m *SMSMock) List() []*smsMessage

List returns a newest-first snapshot of the inbox. Messages are copied so callers can read fields without racing concurrent mutators (SetStatus).

func (*SMSMock) RegisterIngestRoutes

func (m *SMSMock) RegisterIngestRoutes(mux *http.ServeMux)

RegisterIngestRoutes mounts the capture sink. handleIngest is server-to-server (no browser Origin) — intentionally NOT origin-guarded; see guardUnsafe.

func (*SMSMock) RegisterRoutes

func (m *SMSMock) RegisterRoutes(mux *http.ServeMux)

RegisterRoutes mounts both the UI and the ingest endpoint on mux. Do not register twice on the same mux — http.ServeMux panics on duplicate patterns.

func (*SMSMock) RegisterUIRoutes

func (m *SMSMock) RegisterUIRoutes(mux *http.ServeMux)

RegisterUIRoutes mounts the human-facing inbox UI. Split from the ingest sink so the two can live on separate listeners (see `hamr mock-serve`).

func (*SMSMock) SetStatus

func (m *SMSMock) SetStatus(id, status, note string) bool

SetStatus marks a stored message as having a particular outcome. Used by the UI to simulate post-hoc failure/delay on an already-captured message. Allowed values: "failed", "delayed" ("delivered" is the implicit default set at ingest and cannot be re-applied here). Unknown values are rejected.

Persistence: rewrites the whole JSONL file.

type SMSMockOptions

type SMSMockOptions struct {
	MaxMessages    int         // default 500
	PersistPath    string      // "" disables persistence
	OnPersistError func(error) // invoked on disk write/read errors; nil is silent
}

SMSMockOptions configures an SMSMock at construction.

type SSEBroker

type SSEBroker struct {
	// contains filtered or unexported fields
}

SSEBroker manages SSE client connections and broadcasts events.

func NewSSEBroker

func NewSSEBroker(rules []WatchRule, daemons []Daemon, dockerCompose []DockerCompose, mailMockEnabled, smsMockEnabled, stripeMockEnabled, consoleCaptureEnabled, darkFilter bool) *SSEBroker

NewSSEBroker creates a new SSE broker. The provided watch rules, daemons, and docker compose entries are serialized once and sent to each client on connect as a "config" event. The mock flags decide which mock-shortcut buttons the dev panel renders. consoleCaptureEnabled toggles the browser-console transport client-side: true tells the injected reload script to patch console + open /__hamr/console; false tells it to do nothing. darkFilter seeds the dark comfort filter state (see SSEBroker.darkFilter).

func (*SSEBroker) Broadcast

func (b *SSEBroker) Broadcast(evt SSEEvent)

Broadcast sends an event to all connected clients. Non-blocking: if a client's buffer is full, the event is dropped for that client.

func (*SSEBroker) ClientCount

func (b *SSEBroker) ClientCount() int

ClientCount returns the number of connected SSE clients.

func (*SSEBroker) Handler

func (b *SSEBroker) Handler() http.HandlerFunc

Handler returns an http.HandlerFunc that serves SSE connections.

type SSEEvent

type SSEEvent struct {
	Type string // event type (e.g., "reload", "css")
	Data string // event data
}

SSEEvent is a server-sent event.

type StringOrSlice

type StringOrSlice []string

StringOrSlice accepts either a single string or a list of strings in TOML.

func (*StringOrSlice) UnmarshalTOML

func (s *StringOrSlice) UnmarshalTOML(data any) error

UnmarshalTOML implements the toml.Unmarshaler interface.

type StripeAccountSummary

type StripeAccountSummary struct {
	ID             string `json:"id"`
	Email          string `json:"email"`
	ChargesEnabled bool   `json:"chargesEnabled"`
}

type StripeConfig

type StripeConfig struct {
	Enabled       bool   `toml:"enabled"`
	WebhookURL    string `toml:"webhook_url"`    // required when Enabled
	WebhookSecret string `toml:"webhook_secret"` // required when Enabled
	Persist       *bool  `toml:"persist"`        // default true
	PersistPath   string `toml:"persist_path"`   // default ".hamr/stripe/state.json"
}

StripeConfig holds the [dev.stripe] table for the local Stripe mock. When Enabled is true, hamr dev mounts a Stripe-compatible HTTP backend on the proxy mux at /v1/* so real stripe-go clients can talk to it via stripe.SetBackend(...) pointing at the proxy URL. Requires [proxy] to be configured (the API and dev UI both live on the proxy mux).

The mock is dev-only: no production safeguards. Apps gate by leaving STRIPE_MOCK unset in production so stripe-go reaches api.stripe.com.

Webhook delivery: when an outcome is recorded (paid/failed/cancelled), the mock fires a real signed webhook to WebhookURL with WebhookSecret, exactly as Stripe would. The app's existing webhook handler (using stripe-go's webhook.ConstructEvent) verifies and processes it unchanged.

func (StripeConfig) PersistEnabled

func (c StripeConfig) PersistEnabled() bool

PersistEnabled returns whether persistence is on. Defaults to true when the field is unset (nil) — matches the email mock's behaviour.

func (StripeConfig) ResolvedPersistPath

func (c StripeConfig) ResolvedPersistPath() string

ResolvedPersistPath returns PersistPath with the default applied.

type StripeLineItemSummary

type StripeLineItemSummary struct {
	Name       string `json:"name"`
	UnitAmount int64  `json:"unitAmount"`
	Quantity   int64  `json:"quantity"`
}

type StripeMock

type StripeMock struct {
	// contains filtered or unexported fields
}

StripeMock is a dev-only in-memory Stripe backend. Routes implement enough of /v1/* for stripe-go to round-trip CheckoutSession create/retrieve.

func NewStripeMock

func NewStripeMock(opts StripeMockOptions) *StripeMock

NewStripeMock returns a mock backend, loading any persisted state if PersistPath is set. Load failures are reported via OnPersistError but never fatal — the in-memory state simply starts empty.

All log lines from the mock are prefixed with [hamr:stripe] (via the "component" slog attr that the dev handler interprets as a tag override) so they're distinguishable from the rest of `hamr dev`'s output.

func (*StripeMock) FireEvent

func (m *StripeMock) FireEvent(ctx context.Context, eventType string, dataObject map[string]any) error

FireEvent delivers a signed Stripe webhook to the configured endpoint. The dataObject is the Stripe resource that triggered the event (e.g. a serialized CheckoutSession for a checkout.session.completed event); it is embedded under data.object exactly as Stripe would do.

Returns nil when no endpoint is configured (silent drop). On delivery failure or non-2xx response, returns an error so callers can log/surface. This call is synchronous — for fire-and-forget semantics, wrap in a goroutine.

func (*StripeMock) RegisterAPIRoutes

func (m *StripeMock) RegisterAPIRoutes(mux *http.ServeMux)

RegisterAPIRoutes mounts the Stripe API endpoints on mux. The mux MUST be served at the root of its listener (e.g. on a dedicated stripe-only port) because stripe-go validates that req.URL.Path starts with /v1 and rejects anything served under a sub-path.

POST /v1/checkout/sessions       — create session
GET  /v1/checkout/sessions/{id}  — retrieve session

func (*StripeMock) RegisterRoutes

func (m *StripeMock) RegisterRoutes(mux *http.ServeMux)

RegisterRoutes mounts all Stripe mock endpoints (API + UI) on mux.

func (*StripeMock) RegisterUIRoutes

func (m *StripeMock) RegisterUIRoutes(mux *http.ServeMux)

RegisterUIRoutes mounts the dev-facing checkout page + outcome handler on mux. These routes live on the proxy mux (/__hamr/* namespace), separate from the Stripe-API routes which require a path-free root and run on the dedicated stripe listener.

GET  /__hamr/stripe/checkout?session=<id>  — pick-an-outcome page
POST /__hamr/stripe/complete               — record outcome, fire webhook, redirect

func (*StripeMock) SetWebhookEndpoint

func (m *StripeMock) SetWebhookEndpoint(ep WebhookEndpoint)

SetWebhookEndpoint configures the destination + signing secret for events fired via FireEvent. Replaces any previously configured endpoint. An endpoint with empty URL or Secret silently drops events at FireEvent time — useful so callers don't have to gate every fire.

type StripeMockOptions

type StripeMockOptions struct {
	// BaseURL is the proxy origin (scheme + host + port) used to build the
	// hosted-checkout URL returned in CheckoutSession.URL. Required.
	BaseURL string

	// Logger receives errors from async webhook fanout. Defaults to slog.Default().
	Logger *slog.Logger

	// PersistPath enables JSON-file persistence of all in-memory state.
	// When set, state is loaded on construction (corrupt/missing files are
	// silently tolerated) and the entire state is atomically rewritten on
	// every mutation. Empty = in-memory only.
	PersistPath string

	// OnPersistError is invoked whenever a load or write fails. Typically
	// wired to a slog.Warn so dev failures surface in `hamr dev` output.
	// Nil = silent.
	OnPersistError func(error)
}

StripeMockOptions configures a StripeMock at construction.

type StripeObjectSummary

type StripeObjectSummary struct {
	ID       string `json:"id"`
	Status   string `json:"status"`
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
}

type StripeSessionSummary

type StripeSessionSummary struct {
	ID        string                  `json:"id"`
	Status    string                  `json:"status"`
	Amount    int64                   `json:"amount"`
	Currency  string                  `json:"currency"`
	URL       string                  `json:"url"`
	LineItems []StripeLineItemSummary `json:"lineItems,omitempty"`
}

StripeSessionSummary adds the hosted-checkout URL and line items so an agent can verify it's acting on the right session before completing/expiring it.

type StripeStateSummary

type StripeStateSummary struct {
	Sessions       []StripeSessionSummary `json:"sessions"`
	PaymentIntents []StripeObjectSummary  `json:"paymentIntents"`
	Payouts        []StripeObjectSummary  `json:"payouts"`
	Refunds        []StripeObjectSummary  `json:"refunds"`
	Accounts       []StripeAccountSummary `json:"accounts"`
}

StripeStateSummary is the read-only snapshot returned by stripe.list.

type VersionStatus

type VersionStatus int

VersionStatus indicates the CLI-vs-project version state.

const (
	VersionOK       VersionStatus = iota // versions match or no project version
	VersionDev                           // CLI is a dev build
	VersionMismatch                      // CLI major.minor differs from project
	VersionUpdate                        // newer version available on GitHub
)

type WatchRule

type WatchRule struct {
	Name     string        `toml:"name"`
	Watch    StringOrSlice `toml:"watch"`
	Ignore   StringOrSlice `toml:"ignore"`
	Cmd      string        `toml:"cmd"`
	Run      string        `toml:"run"`
	Dir      string        `toml:"dir"`
	Depends  StringOrSlice `toml:"depends"`
	Debounce Duration      `toml:"debounce"`
	Reload   ReloadScope   `toml:"reload"`
	Env      []string      `toml:"env"`
}

WatchRule defines a single watch/build/run rule.

Dir sets the working directory for cmd and run, relative to the directory hamr dev runs in. It does NOT affect watch/ignore globs — those stay root-relative regardless, so a rule can watch the whole repo while building inside a subdirectory.

type Watcher

type Watcher struct {
	// contains filtered or unexported fields
}

Watcher watches the filesystem for changes and emits FileEvents.

func NewWatcher

func NewWatcher(root string, rules []WatchRule, logger *slog.Logger) (*Watcher, error)

NewWatcher creates a file watcher for the given rules. root is the base directory to watch (typically ".").

func (*Watcher) Done

func (w *Watcher) Done() <-chan struct{}

Done returns a channel that is closed when the watcher loop exits.

func (*Watcher) Events

func (w *Watcher) Events() <-chan FileEvent

Events returns the channel that receives file events.

func (*Watcher) Start

func (w *Watcher) Start(ctx context.Context) error

Start begins watching the root directory recursively. It returns immediately and runs in the background until Stop is called or ctx is done.

func (*Watcher) Stop

func (w *Watcher) Stop()

Stop stops the watcher and waits for the event loop to exit.

type WebhookEndpoint

type WebhookEndpoint struct {
	URL    string
	Secret string
}

WebhookEndpoint is where signed events are delivered. URL is the absolute HTTP(S) URL of the app's webhook handler; Secret is the shared signing secret used to compute the Stripe-Signature header.

Directories

Path Synopsis
Package tui implements the bubbletea-based dev runtime that backs `hamr dev`.
Package tui implements the bubbletea-based dev runtime that backs `hamr dev`.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL