From e0085d96ef7b4bc65d2bb2477c8de4cdb418af16 Mon Sep 17 00:00:00 2001 From: Gwendal Leclerc Date: Sat, 18 Jul 2026 17:32:28 +0200 Subject: [PATCH 1/7] fix(release): treat a leading-v semver tag as latest for the docker image IS_SEMVER only matched X.Y.Z, so a stable vX.Y.Z tag would publish a GitHub release marked "Latest" without updating the docker :latest tag. Accept an optional leading v so vX.Y.Z and X.Y.Z both push :latest, while -preview/-draft suffixes stay non-latest. Co-Authored-By: Claude Opus 4.8 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8bf7dd0..d0206b0 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ DOCKER_IMAGE=ghcr.io/smocker-dev/smocker # A tag name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes. # A tag name may not start with a period or a dash and may contain a maximum of 128 characters. DOCKER_TAG:=$(shell echo $(VERSION) | tr -cd '[:alnum:]_.-') -IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$") +IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^v?[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$") LEVEL=debug From 1e1fadb57508885bd906961327cd25b918d8d7bb Mon Sep 17 00:00:00 2001 From: Gwendal Leclerc Date: Sat, 18 Jul 2026 17:53:18 +0200 Subject: [PATCH 2/7] fix: two confirmed UI/server bugs from the issue triage - #309: 'Copy as curl' no longer wraps a raw/urlencoded body in extra quotes (only JSON bodies are serialized; a string body is used as-is). - #313: response headers are served with the exact casing the mock declares instead of Go's canonical form (e.g. 'BrokerProperties' stays as-is), matching servers that treat header names case-sensitively. Co-Authored-By: Claude Opus 4.8 --- client/modules/utils.test.ts | 3 +-- client/modules/utils.ts | 8 +++++++- server/handlers/mocks.go | 9 +++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/client/modules/utils.test.ts b/client/modules/utils.test.ts index f66f5f7..93d2a36 100644 --- a/client/modules/utils.test.ts +++ b/client/modules/utils.test.ts @@ -115,8 +115,7 @@ describe("Generate curl command from:", () => { }, }; expect(entryToCurl(entry)).toBe( - // FIXME: we shouldn't have the " if the client sent raw text - `curl -XPOST '/test' --data '"value containing \\'single quotes\\'"'`, + `curl -XPOST '/test' --data 'value containing \\'single quotes\\''`, ); }); }); diff --git a/client/modules/utils.ts b/client/modules/utils.ts index 739f6ed..c7d8c87 100644 --- a/client/modules/utils.ts +++ b/client/modules/utils.ts @@ -60,7 +60,13 @@ export const entryToCurl = (historyEntry: Entry): string => { ); if (request.body) { - command.push(`--data '${escapeQuote(JSON.stringify(request.body))}'`); + // A raw/urlencoded body is already a string; only JSON bodies need serializing. Stringifying + // a string would wrap it in extra quotes (--data '"a=b"'). + const data = + typeof request.body === "string" + ? request.body + : JSON.stringify(request.body); + command.push(`--data '${escapeQuote(data)}'`); } return command.join(" "); diff --git a/server/handlers/mocks.go b/server/handlers/mocks.go index e7a4c55..63fa48c 100644 --- a/server/handlers/mocks.go +++ b/server/handlers/mocks.go @@ -121,11 +121,12 @@ func (m *Mocks) GenericHandler(c echo.Context) error { /* Response writing */ - // Headers + // Headers: assign to the header map directly instead of Add()/Set(), which canonicalize the + // key (e.g. "BrokerProperties" -> "Brokerproperties"). Smocker preserves the exact casing the + // mock declares, matching servers that treat header names case-sensitively. + header := c.Response().Header() for key, values := range response.Headers { - for _, value := range values { - c.Response().Header().Add(key, value) - } + header[key] = []string(values) } // Delay From 7acaeb620077dc8d42d1141b274251f947408484 Mon Sep 17 00:00:00 2001 From: Gwendal Leclerc Date: Sat, 18 Jul 2026 18:05:03 +0200 Subject: [PATCH 3/7] feat(build): embed the web UI in the binary + smoke-test the docker image #180: the built client is baked into the binary with go:embed, so a released binary/image is self-contained. --static-files still takes precedence when it holds an index.html (development and explicit overrides), otherwise the embedded UI is served. A committed dist/.gitkeep keeps 'go build' working before the client is built; the release build and Dockerfile copy the client into the embed source. The docker image no longer ships a separate client directory. #68: add a 'smoke-docker' target (config API answers, embedded UI is served, and a registered mock is served on the mock port) and run it in CI after start-docker. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/main.yml | 3 +++ .gitignore | 3 +++ Dockerfile | 4 +++- Makefile | 25 ++++++++++++++++++++++--- server/admin_server.go | 34 +++++++++++++++++++++++++++++----- server/frontend/dist/.gitkeep | 0 server/frontend/frontend.go | 27 +++++++++++++++++++++++++++ 7 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 server/frontend/dist/.gitkeep create mode 100644 server/frontend/frontend.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5989832..f26a64a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -108,6 +108,9 @@ jobs: make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} build-docker make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} start-docker + - name: Smoke-test the docker image + run: make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} smoke-docker + - if: startsWith(github.ref, 'refs/tags/') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.gitignore b/.gitignore index 893be12..219d6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ # All build, test and runtime output goes here (see Makefile / vite.config.ts / playwright.config.ts). build/ +# The embedded client is copied here by the release build; keep only the placeholder in git. +server/frontend/dist/* +!server/frontend/dist/.gitkeep diff --git a/Dockerfile b/Dockerfile index 5084dd0..12afdb4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,14 @@ COPY go.mod go.sum ./ RUN go mod download COPY Makefile main.go ./ COPY server/ ./server/ +# The client is built on the host (make release); bake it into the binary via go:embed. +COPY build/client ./server/frontend/dist/ RUN make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build FROM alpine LABEL org.opencontainers.image.source="https://github.com/smocker-dev/smocker" WORKDIR /opt EXPOSE 8080 8081 -COPY build/client client/ +# The UI is embedded in the binary, so no client directory is copied here. COPY --from=build-backend /go/src/smocker/build/* /opt/ CMD ["/opt/smocker"] diff --git a/Makefile b/Makefile index d0206b0..1459e26 100644 --- a/Makefile +++ b/Makefile @@ -155,6 +155,21 @@ build-docker: start-docker: check-default-ports docker run -d -p 8080:8080 -p 8081:8081 --name $(APPNAME) $(DOCKER_IMAGE):$(DOCKER_TAG) +# Smoke-test the running container (started by start-docker): the config API answers, the UI is +# served (proves the embedded client works — the image ships no client directory), and a mock +# registered on the config port is served on the mock port. +.PHONY: smoke-docker +smoke-docker: + @for i in $$(seq 1 30); do curl -sf http://localhost:8081/version >/dev/null 2>&1 && break; sleep 1; done + @curl -sf http://localhost:8081/version >/dev/null || { echo "FAIL: /version"; exit 1; } + @curl -sf http://localhost:8081/ | grep -q Smocker || { echo "FAIL: embedded UI not served"; exit 1; } + @curl -sf -XPOST http://localhost:8081/mocks -H "Content-Type: application/json" \ + --data '[{"request":{"method":"GET","path":"/smoke"},"response":{"status":200,"body":"ok"}}]' >/dev/null \ + || { echo "FAIL: register mock"; exit 1; } + @test "$$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/smoke)" = "200" \ + || { echo "FAIL: mock round-trip"; exit 1; } + @echo "docker smoke tests passed" + .PHONY: check-default-ports check-default-ports: @lsof -i:8080 > /dev/null && (echo "Port 8080 already in use"; exit 1) || true @@ -168,11 +183,15 @@ optimize: # The following targets are only available for CI usage build/smocker.tar.gz: - $(MAKE) build + # Build the client first, copy it into the embed source, then build the Go binary so the UI + # is baked in (go:embed). The result is a self-contained binary — no client dir to ship. npm ci --ignore-scripts npm run build - # Package only the release artifacts, not test/runtime output that may also live in build/. - cd build/; tar -czvf smocker.tar.gz smocker client + rm -rf server/frontend/dist && mkdir -p server/frontend/dist + cp -r build/client/. server/frontend/dist/ + touch server/frontend/dist/.gitkeep + $(MAKE) build + cd build/; tar -czvf smocker.tar.gz smocker .PHONY: release release: build/smocker.tar.gz diff --git a/server/admin_server.go b/server/admin_server.go index 8417516..1c3851c 100644 --- a/server/admin_server.go +++ b/server/admin_server.go @@ -18,6 +18,7 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/smocker-dev/smocker/server/config" + "github.com/smocker-dev/smocker/server/frontend" "github.com/smocker-dev/smocker/server/handlers" "github.com/smocker-dev/smocker/server/services" "golang.org/x/sync/errgroup" @@ -79,8 +80,14 @@ func Serve(config config.Config) { return c.JSON(http.StatusOK, config.Build) }) - // UI Routes - adminServerEngine.Static("/assets", config.StaticFiles) + // UI Routes: serve from --static-files on disk when it holds a built index.html (development + // or an explicit override); otherwise fall back to the client embedded in the binary, so a + // released binary is self-contained. + if fileExists(config.StaticFiles + "/index.html") { + adminServerEngine.Static("/assets", config.StaticFiles) + } else if embedded, ok := frontend.FS(); ok { + adminServerEngine.StaticFS("/assets", embedded) + } adminServerEngine.GET("/*", renderIndex(adminServerEngine, config)) slog.Info("Starting admin server", "port", config.ConfigListenPort) @@ -153,15 +160,32 @@ func serve(tlsEnable bool, servers ...*http.Server) error { return g.Wait() } +// fileExists reports whether path exists (used to prefer an on-disk UI over the embedded one). +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + func renderIndex(e *echo.Echo, cfg config.Config) echo.HandlerFunc { return func(c echo.Context) error { - // In development mode, index.html might not be available yet + // Parse index.html once: from --static-files on disk if present (dev/override), otherwise + // from the client embedded in the binary. In development the client may not be built yet. if templateRenderer == nil { - template, err := template.ParseFiles(cfg.StaticFiles + "/index.html") + var ( + tmpl *template.Template + err error + ) + if diskIndex := cfg.StaticFiles + "/index.html"; fileExists(diskIndex) { + tmpl, err = template.ParseFiles(diskIndex) + } else if embedded, ok := frontend.FS(); ok { + tmpl, err = template.ParseFS(embedded, "index.html") + } else { + return c.String(http.StatusNotFound, "index is building...") + } if err != nil { return c.String(http.StatusNotFound, "index is building...") } - templateRenderer := &TemplateRenderer{template} + templateRenderer = &TemplateRenderer{tmpl} e.Renderer = templateRenderer } diff --git a/server/frontend/dist/.gitkeep b/server/frontend/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/frontend/frontend.go b/server/frontend/frontend.go new file mode 100644 index 0000000..5e56c5c --- /dev/null +++ b/server/frontend/frontend.go @@ -0,0 +1,27 @@ +// Package frontend embeds the built web UI so a released binary is self-contained (no need to +// ship the client directory alongside it). The dist directory is populated by the client build +// (see the Makefile release target); a committed placeholder keeps `go build` working before the +// client has ever been built. +package frontend + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +// FS returns the embedded client filesystem (rooted at dist) together with whether it actually +// holds a built UI (an index.html). When only the placeholder is present it returns ok=false, so +// callers fall back to serving from the --static-files directory on disk. +func FS() (fs.FS, bool) { + sub, err := fs.Sub(dist, "dist") + if err != nil { + return nil, false + } + if _, err := fs.Stat(sub, "index.html"); err != nil { + return nil, false + } + return sub, true +} From 8c03bcbe58b6bf2addf9784cb8928b49eb95d9f1 Mon Sep 17 00:00:00 2001 From: Gwendal Leclerc Date: Sat, 18 Jul 2026 18:16:10 +0200 Subject: [PATCH 4/7] build(docker): ship the image FROM scratch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the binary statically (CGO_ENABLED=0) and put it on a scratch base instead of alpine: the UI is already embedded (go:embed) and CA roots are copied in for the proxy mock type's HTTPS backends, so nothing else is needed. Result is an ~18 MB image with no OS/shell — smaller and a much smaller attack surface. Validated with podman: embedded UI served, local mock round-trip, and an HTTPS proxy call all succeed. Co-Authored-By: Claude Opus 4.8 --- Dockerfile | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 12afdb4..1c1de12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG GO_VERSION=1.26 FROM golang:${GO_VERSION}-alpine AS build-backend -RUN apk add --no-cache make +RUN apk add --no-cache make ca-certificates ARG VERSION=snapshot ARG COMMIT WORKDIR /go/src/smocker @@ -10,12 +10,14 @@ COPY Makefile main.go ./ COPY server/ ./server/ # The client is built on the host (make release); bake it into the binary via go:embed. COPY build/client ./server/frontend/dist/ -RUN make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build +# CGO_ENABLED=0 produces a fully static binary (pure-Go net/tls), required for a scratch image. +RUN CGO_ENABLED=0 make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build -FROM alpine +FROM scratch LABEL org.opencontainers.image.source="https://github.com/smocker-dev/smocker" -WORKDIR /opt EXPOSE 8080 8081 -# The UI is embedded in the binary, so no client directory is copied here. -COPY --from=build-backend /go/src/smocker/build/* /opt/ -CMD ["/opt/smocker"] +# CA roots so the proxy mock type can reach HTTPS backends (scratch ships none). +COPY --from=build-backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +# The UI is embedded in the binary, so nothing else is needed — just the static binary. +COPY --from=build-backend /go/src/smocker/build/smocker /smocker +ENTRYPOINT ["/smocker"] From be4d5a8c0d9420edefd4f5bdaff63a62d5e13c41 Mon Sep 17 00:00:00 2001 From: Gwendal Leclerc Date: Sat, 18 Jul 2026 18:22:20 +0200 Subject: [PATCH 5/7] build(docker): run the container as a non-root user (nobody) Add USER 65534:65534 to the scratch image so smocker runs unprivileged by default. The listen ports are >1024 (no privilege needed to bind); a mounted persistence directory must be writable by this uid. Validated with podman: the process runs as uid 65534 and still serves the UI, mock round-trips, and HTTPS proxy calls. Co-Authored-By: Claude Opus 4.8 --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 1c1de12..e65b20b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,4 +20,7 @@ EXPOSE 8080 8081 COPY --from=build-backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt # The UI is embedded in the binary, so nothing else is needed — just the static binary. COPY --from=build-backend /go/src/smocker/build/smocker /smocker +# Run unprivileged (nobody). The listen ports are >1024 so no privilege is needed; a mounted +# persistence directory must be writable by this uid. +USER 65534:65534 ENTRYPOINT ["/smocker"] From 7bfc34daf0a334f8ac24352af473e2f17c3e18ea Mon Sep 17 00:00:00 2001 From: Gwendal Leclerc Date: Sat, 18 Jul 2026 18:51:35 +0200 Subject: [PATCH 6/7] fix(dev): make 'npm run dev' work against the backend The Vite dev server served index.html with the raw Go template placeholders ({{.basePath}}/{{.version}}) and had no API proxy, so the app's API base and were broken and calls hit the SPA fallback. Add a serve-only transformIndexHtml that substitutes the placeholders in dev, a server.proxy for the admin API routes to the backend (SMOCKER_DEV_PROXY overridable), and serve from base '/' in dev while the build keeps './' for the Go . The production build is unchanged (placeholders + assets/ prefix preserved). Update the README dev commands (yarn -> npm) accordingly. Co-Authored-By: Claude Opus 4.8 --- README.md | 18 +++++++++--------- vite.config.ts | 45 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 861353b..0beee1d 100644 --- a/README.md +++ b/README.md @@ -167,15 +167,15 @@ The backend is written in Go. You can use the following commands to manage the d ### Frontend -The frontend is written with TypeScript and React. You can use the following commands to manage the development lifecycle: - -- `yarn install`: install the dependencies -- `yarn start`: start the frontend in development mode, with live reload -- `yarn build`: generate the transpiled and minified files and assets -- `yarn lint`: run static analysis on the code -- `yarn format`: automatically format the frontend code -- `yarn test`: execute unit tests -- `yarn test:watch`: execute unit tests, with live reload +The frontend is written with TypeScript and React, bundled with Vite. You can use the following commands to manage the development lifecycle: + +- `npm install`: install the dependencies +- `npm run dev`: start the Vite dev server with hot reload. It proxies the admin API to the backend, so run `make start` alongside it (override the target with `SMOCKER_DEV_PROXY` if the backend is elsewhere) +- `npm run build`: generate the transpiled and minified files and assets +- `npm run lint`: run static analysis on the code +- `npm run format`: automatically format the frontend code +- `npm test`: execute unit tests +- `npm run test:watch`: execute unit tests, with live reload ### Docker diff --git a/vite.config.ts b/vite.config.ts index 7d0f042..008be28 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,7 @@ function assetsPrefix(): Plugin { let indexPath = ""; return { name: "smocker-assets-prefix", + apply: "build", configResolved(cfg) { indexPath = resolve(cfg.root, cfg.build.outDir, "index.html"); }, @@ -30,13 +31,43 @@ function assetsPrefix(): Plugin { }; } -export default defineConfig({ +// In the dev server, Vite serves index.html without the Go template step, so the runtime globals +// stay as literal "{{.basePath}}" / "{{.version}}" and break the API base + . Substitute +// them for dev only; the production build keeps the placeholders for the Go server to render. +function devIndexHtml(): Plugin { + return { + name: "smocker-dev-index-html", + apply: "serve", + transformIndexHtml(html) { + return html + .replace(/\{\{\.basePath\}\}/g, "/") + .replace(/\{\{\.version\}\}/g, "dev"); + }, + }; +} + +// Where `npm run dev` proxies the API to (the running `make start` admin server). +const devProxyTarget = process.env.SMOCKER_DEV_PROXY ?? "http://localhost:8081"; +const apiProxy = Object.fromEntries( + ["/mocks", "/sessions", "/history", "/version", "/reset"].map((path) => [ + path, + devProxyTarget, + ]), +); + +export default defineConfig(({ command }) => ({ root: "client", - base: "./", - plugins: [react(), assetsPrefix()], - // Allow importing the canonical mock schema (docs/mock.schema.json) from client code, which - // lives above the Vite root. - server: { fs: { allow: [import.meta.dirname] } }, + // Dev serves from "/" (clean absolute URLs against the dev server); the build keeps "./" so the + // Go server's can relocate assets under any deployment base path. + base: command === "build" ? "./" : "/", + plugins: [react(), assetsPrefix(), devIndexHtml()], + server: { + // Allow importing the canonical mock schema (docs/mock.schema.json) from client code, which + // lives above the Vite root. + fs: { allow: [import.meta.dirname] }, + // Proxy the admin API to the backend so `npm run dev` works against `make start`. + proxy: apiProxy, + }, build: { outDir: "../build/client", emptyOutDir: true, @@ -48,4 +79,4 @@ export default defineConfig({ // Relative to the Vite root ("client"), so this matches client/**/*.test.{ts,tsx}. include: ["**/*.test.{ts,tsx}"], }, -}); +})); From d41dc2a193b0251b4fc767e2668ac9de0c938099 Mon Sep 17 00:00:00 2001 From: Gwendal Leclerc Date: Sat, 18 Jul 2026 20:53:13 +0200 Subject: [PATCH 7/7] chore: extend Dependabot (gomod, npm), check the go-httpbin port, note the v tag convention - Dependabot now also updates Go modules and npm packages (grouped, weekly), not just actions. - check-default-ports also verifies the go-httpbin proxy port (PROXY_TARGET_PORT, 8090). - Document the 'vX.Y.Z' tag convention in the CI trigger comments (bare X.Y.Z still works). Co-Authored-By: Claude Opus 4.8 --- .github/dependabot.yml | 20 ++++++++++++++++++++ .github/workflows/main.yml | 9 +++++---- Makefile | 1 + 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a16ddc3..7875507 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,23 @@ updates: github-actions: patterns: - "*" + + # Go module dependencies. + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + groups: + gomod: + patterns: + - "*" + + # Frontend (npm) dependencies. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + npm: + patterns: + - "*" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f26a64a..fdfb661 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,11 +3,12 @@ on: push: branches: - main - # Release flavor is derived from the tag suffix in the deploy job: + # Release flavor is derived from the tag suffix in the deploy job. Convention: prefix tags + # with "v" (bare X.Y.Z is still accepted for backward compatibility). tags: - - '*.*.*' # stable semver (e.g. 1.2.3): published and marked "Latest" - - '*-preview' # pre-release (e.g. 1.2.3-preview): published, never "Latest" - - '*-draft' # draft (e.g. 1.2.3-draft): unpublished for review, never "Latest" + - '*.*.*' # stable semver (e.g. v1.2.3): published and marked "Latest" + - '*-preview' # pre-release (e.g. v1.2.3-preview): published, never "Latest" + - '*-draft' # draft (e.g. v1.2.3-draft): unpublished for review, never "Latest" pull_request: branches: - main diff --git a/Makefile b/Makefile index 1459e26..5670b2f 100644 --- a/Makefile +++ b/Makefile @@ -174,6 +174,7 @@ smoke-docker: check-default-ports: @lsof -i:8080 > /dev/null && (echo "Port 8080 already in use"; exit 1) || true @lsof -i:8081 > /dev/null && (echo "Port 8081 already in use"; exit 1) || true + @lsof -i:$(PROXY_TARGET_PORT) > /dev/null && (echo "Port $(PROXY_TARGET_PORT) (go-httpbin) already in use"; exit 1) || true .PHONY: optimize optimize: