7 Commits

Author SHA1 Message Date
clawbot
1c16d50d67 build: unify the gate so root make check covers the backend (closes #16)
All checks were successful
check / check (push) Successful in 32s
Root `make check` only ever ran the frontend, so the "main is always
green" policy was satisfied vacuously: the Go backend could be entirely
broken and the root gate stayed green.

- The backend moves onto scripts-to-rule-them-all. Its test, lint, fmt,
  fmt-check, build, run and clean implementations now live in
  `backend/script/`, and `backend/Makefile` is thin shims. The backend
  is its own project (own module, README, LICENSE, linter config,
  Dockerfile stage), and `Dockerfile.backend` only copies `backend/`
  into its builder, so its scripts have to live under `backend/`.
- The root `script/test`, `script/lint`, `script/fmt` and
  `script/fmt-check` now run the frontend step and then the matching
  `backend/script/*` step, so `script/check` — and therefore the
  pre-commit hook — gates both halves. The frontend-only steps moved
  into `script/frontend-*` so nothing is duplicated.
- `script/bootstrap` now provisions the backend's toolchain as well,
  because widening the gate without widening bootstrap left the
  documented fresh-clone path (`make setup`) installing a pre-commit
  hook that rejected every commit with `golangci-lint: not found`.
  golangci-lint is installed at exactly `2.7.2`, the version
  `Dockerfile.backend` pins, so local findings match CI. Go is reused
  only when the installed version falls inside a window — at least
  `backend/go.mod`'s floor, and no newer in major.minor than the Go the
  pinned linter was built with — otherwise `go1.25.7` is installed. The
  upper bound is load-bearing: golangci-lint links `go/types` from its
  own build toolchain, so the pinned `2.7.2` (built with `go1.25.4`)
  panics with "file requires newer Go version go1.26" against a host Go
  1.26, which would leave `make setup` exiting 0 and every commit
  rejected. Both tools come from a specific release archive whose sha256
  is hardcoded here and verified before anything is unpacked — never an
  install script piped to a shell — and both are symlinked onto `PATH`,
  since nvm-style activation does not reach `make` or the git hook.
- `script/bootstrap` links only into `~/.local/bin` and never into a
  system-wide prefix. `/usr/local/bin` is shared with other users and
  with a package manager — on an Intel Mac it is the Homebrew prefix —
  and pointing an entry there at one user's `$HOME` breaks it for
  everyone else. It also refuses, non-zero, to replace anything it did
  not create: only a symlink already pointing into its own toolchain
  directory is overwritten, so a pre-existing binary is reported rather
  than deleted. `corepack enable` is given `--install-directory` so its
  four shims (`yarn`, `yarnpkg`, `pnpm`, `pnpx`) land inside that same
  toolchain directory instead of beside the corepack binary, and only
  `yarn` is linked onto `PATH`.
- `script/bootstrap` exits non-zero when it cannot guarantee the pinned
  toolchain is the one the gate will run. Reporting success while
  knowing a different linter or a newer Go precedes `~/.local/bin` is
  the same defect this commit exists to remove, so the final step
  re-resolves `go`, `gofmt`, `golangci-lint`, `node` and `yarn` against
  the caller's own `PATH` and fails with what it found and how to fix
  it. The three tools that carry a version constraint are re-checked
  with the same predicates their installs use, not for bare presence:
  `gofmt` is a gate tool — `backend/script/fmt-check` runs it — and its
  output is not guaranteed identical across Go releases, so a `gofmt`
  built by a different Go than the one that compiles the code counts as
  missing. `go` and `gofmt` are relinked on every run in which the
  pinned toolchain is the one in use, rather than only on the run that
  unpacked the archive, so a deleted link is repaired instead of
  falling through to whatever `gofmt` the host happens to have. The
  failure text separates a tool that resolves to the wrong build
  (something shadows `~/.local/bin`) from one that does not resolve at
  all (nothing is shadowing it, it was never installed), and always
  names a real directory rather than interpolating an unset one.
- `script/frontend-check` is the frontend half of the gate, exposed as
  the `frontend-check` target, for the frontend Dockerfile: its build
  stage is a node image with no Go toolchain. The backend half is gated
  by `Dockerfile.backend`, and `script/cibuild` builds both images, so
  the two Dockerfiles together still gate the whole repo. The
  `backend-check` target is the mirror of it. Both targets are named
  after the script they shim, like every other target.
- `script/cibuild` builds both images through one `build_image` helper,
  and the Gitea workflow's only build step is `script/cibuild`; the raw
  `docker build -f Dockerfile.backend .` is gone from the workflow.
  `script/docker` likewise builds and tags both images.
- `backend/Makefile`'s `hooks` target is removed. It wrote the same
  `.git/hooks/pre-commit` as `script/install-precommit`, so the two
  clobbered each other and the developer silently ended up gating on
  only one half of the repo. `script/install-precommit` is now the only
  installer, and the hook it writes runs the repo-wide `script/check`.
- `backend/Makefile`'s `docker` target is removed too: the backend image
  builds from the repo root with a root-level Dockerfile, so it belongs
  to the root `script/docker` and `script/cibuild` rather than to a
  backend script that would have to reach outside `backend/`.
- `backend/script/lint` verifies that `.golangci.yml` still matches its
  pinned sha256 before running the linter. Offline hash comparison, no
  network. The pin is marked provisional in the file: it is the config
  currently on `main`, and the comment names PR #31 and the canonical
  hash that must replace it when #31 lands.
- Every script locates the repo root with the mandated
  `$(cd "$(dirname "$0")/.." && pwd -P)` idiom, `cd`s there, and calls
  siblings as `"$ROOT/script/<name>"`; the `SCRIPT_DIR` variant is gone.

READMEs at the root and in `backend/` document every script, the
backend's Getting Started separates commands run from `backend/` from
those run at the repo root, and `TODO.md` records the change.
2026-08-09 07:54:12 +00:00
fbfe1df349 frontend: gate the Docker build on make check (closes #11) (#12)
All checks were successful
check / check (push) Successful in 26s
Resolves #11. The frontend/root `Dockerfile` ran only `RUN yarn build`, so `script/lint` and `script/fmt-check` (prettier) never gated CI — only a broken build failed it. (`script/cibuild`'s comment even claimed "the Dockerfile runs make check", which was false.) The backend `Dockerfile.backend` already runs `make check`; nothing covered the frontend's lint/fmt-check.

Change (single file, `Dockerfile`):
- `apk add ... git` -> `apk add ... git make` (build stage needs `make`).
- `RUN yarn build` -> `RUN make check` — which runs `script/test` (`yarn build`, producing `dist/`) then `script/lint` + `script/fmt-check`. `dist/` is still produced in one build (no redundant rebuild); the final nginx runtime image is unchanged.

Verified via a fresh clone (a worktree's `.git` pointer breaks `vite`'s `git rev-parse`, so builds must come from a real checkout — as CI's `actions/checkout` provides): positive `docker build` succeeds with in-image `make check` green; a negative test (a prettier-violating but build-valid file) makes the build fail at `make check`, confirming CI now goes red on a check regression, not just a broken build.

Left open for review (not merged).

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #12
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 17:46:37 +02:00
e45bc578b2 scripts-to-rule-them-all (#10)
All checks were successful
check / check (push) Successful in 21s
Reviewed-on: #10
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-07 02:14:16 +02:00
247a3c33fd TODO (#9)
All checks were successful
check / check (push) Successful in 22s
Reviewed-on: #9
2026-07-06 21:20:37 +02:00
1fb3ff2954 feat: responsive mobile layout for host rows (closes #2) (#5)
All checks were successful
check / check (push) Successful in 1m10s
Redesigns host rows for portrait/mobile viewports (<=768px):
- Host info panel stacks on top, full width
- Sparkline renders full width below
- Each host row becomes taller to accommodate vertical layout
- Summary line wraps gracefully
- Header controls stack below title

Desktop layout is unchanged — all changes are inside a `@media (max-width: 768px)` query and CSS class hooks added to the HTML.

Closes #2

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #5
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 19:56:40 +01:00
36202e1a3a Merge pull request 'fix: show 'not available on mobile' message instead of broken layout' (#3) from fix/mobile-not-available into main
All checks were successful
check / check (push) Successful in 32s
Reviewed-on: #3
2026-02-27 11:07:11 +01:00
user
38bbd13c7f fix: show 'not available on mobile' message instead of broken layout
All checks were successful
check / check (push) Successful in 28s
Detect mobile devices via user agent and viewport width (<=768px).
On mobile, skip all checker initialization and render only the
header, description, and a styled 'Not yet available on mobile' box.

Desktop behavior is completely unchanged — the mobile check returns
early before any existing code runs.
2026-02-27 02:00:01 -08:00
35 changed files with 1697 additions and 166 deletions

View File

@@ -6,5 +6,6 @@ jobs:
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .
- run: docker build -f Dockerfile.backend .
# script/cibuild builds both images; it is the only build
# step, so how the repo builds stays defined in script/.
- run: script/cibuild

View File

@@ -3,9 +3,16 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
RUN apk add --no-cache git
RUN apk add --no-cache git make
COPY . .
RUN yarn build
# make frontend-check runs script/frontend-check (test + lint +
# fmt-check for the frontend); its test step is the production yarn
# build, so this both produces dist/ and gates the image on
# lint/fmt-check/test regressions, not merely a broken build. It is the
# frontend half of `make check` rather than all of it because this stage
# is a node image with no Go toolchain; the backend half is gated by
# Dockerfile.backend, and script/cibuild builds both images.
RUN make frontend-check
# nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab

View File

@@ -1,21 +1,46 @@
.PHONY: dev test lint fmt fmt-check check docker
.PHONY: bootstrap setup dev test lint fmt fmt-check check frontend-check \
backend-check docker hooks
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md). test, lint, fmt, fmt-check and check all cover the
# whole repo: the frontend at the root and the Go backend in backend/.
bootstrap:
@script/bootstrap
setup:
@script/setup
dev:
yarn dev
test:
timeout 30 yarn build
@script/test
lint:
yarn prettier --check .
@script/lint
fmt:
yarn prettier --write .
@script/fmt
fmt-check:
yarn prettier --check .
@script/fmt-check
check: test lint fmt-check
check:
@script/check
# Half-repo gates. Used by the two Dockerfiles, whose build stages only
# have the toolchain for their own half; prefer `make check` otherwise.
# Each is named after the script it shims, like every other target here.
frontend-check:
@script/frontend-check
backend-check:
@backend/script/check
docker:
timeout 300 docker build -t netwatch .
@script/docker
hooks:
@script/install-precommit

View File

@@ -23,6 +23,70 @@ docker build -t netwatch .
docker run -p 8080:8080 netwatch
```
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them.
The repo holds two projects: the frontend at the repo root and the Go backend in
`backend/`, which has its own `script/` directory and its own shim Makefile. The
root scripts cover both, so `make check` at the root fails if either half is
broken. We provide:
- `script/bootstrap` — install all dependencies, assuming nothing is present:
pinned node via nvm if needed, yarn via corepack,
`yarn install --frozen-lockfile`, and the backend's toolchain — Go (an
already-installed Go is reused only when its version falls inside the window
the pinned golangci-lint can analyse; a newer Go is ignored, not preferred)
and golangci-lint at the version `Dockerfile.backend` pins. Everything not
installed by the system package manager comes from a hash-verified release
archive and is symlinked into `~/.local/bin`, so `make check` works in a plain
shell afterwards
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tags)
- `script/test` — run the whole repo's tests: `script/frontend-test`, then
`backend/script/test`
- `script/lint` — lint the whole repo: `script/frontend-lint`, then
`backend/script/lint`
- `script/fmt` — format the whole repo (writes): `script/frontend-fmt`, then
`backend/script/fmt`
- `script/fmt-check` — check formatting across the whole repo (read-only)
- `script/check` — run test, lint, and fmt-check; this is the repo-wide gate
- `script/frontend-test` — the frontend's test: the production build (no unit
tests yet)
- `script/frontend-lint` — run prettier in check mode
- `script/frontend-fmt` — format everything prettier understands (writes)
- `script/frontend-fmt-check` — check prettier formatting (read-only)
- `script/frontend-check` — the frontend half of `script/check`, shimmed by
`make frontend-check` and used by `Dockerfile`, whose build stage is a node
image with no Go toolchain. Its mirror `make backend-check` shims to
`backend/script/check`
- `script/docker` — build both images, tagged via `script/projectname`:
`netwatch` from `Dockerfile` and `netwatch-server` from `Dockerfile.backend`
- `script/cibuild` — CI entrypoint: builds both images; the only build step in
the Gitea workflow
- `script/precommit` — run by the git pre-commit hook; runs `script/check`, so a
commit is gated on both halves of the repo
- `script/install-precommit` — install the git pre-commit hook; this is the
repo's only pre-commit hook installer
The backend's scripts are shimmed by `backend/Makefile` and are also called by
the root scripts above:
- `backend/script/build` — compile `netwatch-server` with version and
architecture stamped in
- `backend/script/test` — run the Go tests under a 30-second timeout
- `backend/script/lint` — assert `.golangci.yml` still matches its pinned
sha256, then run golangci-lint
- `backend/script/fmt` — format the Go sources (writes)
- `backend/script/fmt-check` — check Go formatting (read-only)
- `backend/script/check` — run the backend's test, lint, and fmt-check
- `backend/script/run` — build and run the server locally
- `backend/script/clean` — remove build artifacts
## Rationale
When debugging network issues, it's useful to have a persistent at-a-glance view

View File

@@ -1,108 +1,408 @@
# Development Policies
---
title: Repository Policies
last_modified: 2026-07-06
---
- Docker image references by tag are server-mutable, therefore using them is an
RCE vulnerability. All docker image references must use cryptographic hashes
to securely specify the exact image that is expected.
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- Correspondingly, `go install` commands using things like '@latest' are also
dangerous RCE. Whenever writing scripts or tools, ALWAYS specify go install
targets using commit hashes which are cryptographically secure.
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
- Every repo with software in it must have a Makefile in the root. Each such
Makefile should support `make test` (runs the project-specific tests),
`make lint`, `make fmt` (writes), `make fmt-check` (readonly), and
`make check` (has `test`, `lint`, and `fmt-check` as prereqs), `make docker`
(builds docker image).
---
- Every repo should have a Dockerfile. If the repo contains non-server software,
the Dockerfile should bring up a development environment and `make check`
(i.e. the docker build should fail if the branch is not green).
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- Platform-specific standard formatting should be used. `black` for python,
`prettier` for js/css/etc, `go fmt` for go. The only changes to default
settings should be to specify four-space indents where applicable (i.e.
everything except `go fmt`).
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- If local testing is possible (it is not always), `make check` should be a
pre-commit hook. If it is not possible, `make lint && make fmt-check` should
be a pre-commit hook.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- If a working `make test` takes more than 20 seconds, that's a bug that needs
fixing. In fact, there should be a timeout specified in the `Makefile` that
fails it automatically if it takes >30s.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Docker builds should time out in 5 minutes or less.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Do all changes on a feature branch. You can do whatever you want on a feature
branch.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- We have a standardized `.golangci.yml` which we reuse and is _NEVER_ to be
modified by an agent, only manually by the user. It can be copied from
`~/dev/upaas/.golangci.yml` if it exists at that location.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- When specifying images or packages by hash in Dockerfiles or
`docker-compose.yml`, put a comment above the line and show the version and
date at which it was current.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- For javascript, always use `yarn` over `npm`.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Whenever writing dates, ALWAYS write YYYY-MM-DD (ISO 8601).
- Never force-push to `main`.
- Simple projects should be configured with environment variables, as is
standard for Dockerized applications.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- Dockerized web services should listen on the default HTTP port of 8080 unless
overridden with the `PORT` environment variable.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- The `README.md` is a project's primary documentation. It should contain at a
minimum the following sections:
- Description
- Include a short and complete description of the functionality and
purpose of the software as the first line in the readme. It must
include:
- the name
- the purpose
- the category (web server, SPA, command line tool, etc)
- the license
- the author
- eg: "µPaaS is an MIT-licensed Go web application by @sneak that
receives git-frontend webhooks and interacts with a Docker server
to build and deploy applications in realtime as certain branches
are updated."
- Getting Started
- a code block with copy-pasteable installation/use sections
- Rationale
- why does this exist?
- Design
- how is the program structured?
- TODO
- This is your TODO list for the project - update it meticulously, even
in between commits. Whenever planning, put your todo list in the
README so that a separate agent with new context can pick up where you
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- License
- GPL or MIT or WTFPL - ask the user when beginning a new project and
include a LICENSE file in the root and in a section in the README.
- Author
- @sneak (link `@sneak` to `https://sneak.berlin`).
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- When beginning a new project, initialize a git repo and make the first commit
simply the first version of the README.md in the root of the repo.
- First commit of a new repo should contain only `README.md`.
- For Go packages, the module root is `sneak.berlin/go/...`, such as
`sneak.berlin/go/dnswatcher`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- We use SemVer always.
- Use SemVer.
- If no tag `1.0.0` or greater exists in the repository, modify the existing
migrations and assume no installed base or existing databases. If `>=1.0.0`,
database changes add new migration files.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- New repos must have at a minimum the following files:
- `README.md`, `.git`, `.gitignore`
- `POLICIES.md` (copy from `~/Documents/_PROMPTS/POLICIES.md`)
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- for go: `go.mod`, `go.sum`, `.golangci.yml`
- for js: `package.json`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

61
TODO.md Normal file
View File

@@ -0,0 +1,61 @@
# Workflow
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0. No git tags. Backend work in flight on feat/reportbuf-storage (dirty:
src/main.js). Frontend is functional; backend is new and unmerged.
# Next Step
Land feat/reportbuf-storage: finish the in-progress src/main.js change, get make
check green, and merge the branch to main. The branch adds the backend (buffered
zstd-compressed report storage), the CI workflow, and backend repo standard
files, so merging it also closes most compliance gaps.
# Completed Steps
- 2026-08-09: unified the gate: the root `make check` now covers the Go backend
as well as the frontend, the backend moved onto scripts-to-rule-them-all
(`backend/script/*` with `backend/Makefile` as thin shims), the duplicate
pre-commit hook installer in `backend/Makefile` was removed, `script/cibuild`
now builds both images as the workflow's only build step, and
`script/bootstrap` provisions the backend toolchain (pinned, hash-verified Go
and golangci-lint) so a fresh clone can pass the widened gate. Bootstrap
matches the Go pin rather than treating it as a floor, because the pinned
golangci-lint cannot analyse packages built by a newer Go; it links only into
`~/.local/bin`, never a system-wide prefix, and refuses to replace anything it
did not create; and it exits non-zero rather than reporting success when the
tools on the caller's `PATH` are not the pinned ones — `gofmt` included, held
to the same version agreement as `go` and relinked on every run so a deleted
link cannot leave another Go's `gofmt` gating the repo
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
and backend repo standard files; backend Dockerfile fixed (Go 1.25,
golangci-lint) and moved to repo root (feat/reportbuf-storage, unmerged)
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing
fixes; nginx config extracted; port hardcoded to 8080
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
S3 Singapore endpoint added
- 2026-02-23: summary box redesign, host pinning, local and UTC clocks, checks
counter
- 2026-02-23: hosts sorted by latency; GET instead of HEAD for latency; timeout
derived from interval; Hetzner regional endpoints; 3s interval
- 2026-01-29: initial NetWatch network latency monitor
# Future Steps
- Compliance top-up as one small commit: add .editorconfig and add the hooks
target to the Makefile
- After merge, confirm .gitea/workflows/check.yml is on main and CI is green
(main always green policy)
- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete
it

View File

@@ -1,53 +1,38 @@
UNAME_S := $(shell uname -s)
VERSION := $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m)
BINARY := netwatch-server
# Standard targets are thin shims; the implementations live in
# backend/script/ per the scripts-to-rule-them-all pattern (see the
# Entrypoints section of README.md).
#
# There is no `hooks` target here: the repo has exactly one pre-commit
# hook installer, the root `script/install-precommit`, and the hook it
# installs gates both halves of the repo. There is no `docker` target
# either: the backend image is built from Dockerfile.backend with the
# repo root as its context, so it belongs to the root `make docker` and
# `script/cibuild`.
GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
ifeq ($(UNAME_S),Darwin)
GOFLAGS := -ldflags "$(GOLDFLAGS)"
else
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)"
endif
.PHONY: all build test lint fmt fmt-check check docker hooks run clean
.PHONY: all build test lint fmt fmt-check check run clean
all: build
build: ./$(BINARY)
./$(BINARY): $(shell find . -name '*.go' -type f) go.mod go.sum
go build -o $@ $(GOFLAGS) ./cmd/netwatch-server/
build:
@script/build
test:
timeout 30 go test ./...
@script/test
lint:
golangci-lint run ./...
@script/lint
fmt:
go fmt ./...
@script/fmt
fmt-check:
@test -z "$$(gofmt -l .)" || \
(echo "Files not formatted:"; gofmt -l .; exit 1)
@script/fmt-check
check: test lint fmt-check
check:
@script/check
docker:
timeout 300 docker build -t netwatch-server -f ../Dockerfile.backend ..
hooks:
@printf '#!/bin/sh\ncd backend && make check\n' > \
$$(git rev-parse --show-toplevel)/.git/hooks/pre-commit
@chmod +x \
$$(git rev-parse --show-toplevel)/.git/hooks/pre-commit
@echo "Pre-commit hook installed"
run: build
./$(BINARY)
run:
@script/run
clean:
rm -f ./$(BINARY)
@script/clean

View File

@@ -4,18 +4,54 @@ SPA and persists them as zstd-compressed JSONL files on disk.
## Getting Started
From this directory (`backend/`):
```bash
# Build and run locally
make run
# Run tests, lint, and format check
# Run the backend's tests, lint, and format check
make check
```
# Docker
docker build -t netwatch-server .
From the repo root, one directory up — `Dockerfile.backend` lives there and its
build context is the repo root, so there is no `docker` target here:
```bash
# Build both images, including netwatch-server
make docker
# Run the backend image
docker run -p 8080:8080 netwatch-server
```
## Entrypoints
This project follows the same
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern as the repo root: the implementations live in `backend/script/` and the
targets in `backend/Makefile` are thin shims that call them. The repo root's
`script/test`, `script/lint`, `script/fmt` and `script/fmt-check` call these
too, so the root `make check` covers the backend.
- `script/build` — compile `netwatch-server` with the version and architecture
stamped in via ldflags (statically linked on Linux)
- `script/test` — run the Go tests under a 30-second timeout
- `script/lint` — assert `.golangci.yml` still matches its pinned sha256, then
run golangci-lint
- `script/fmt` — format the Go sources (writes)
- `script/fmt-check` — check Go formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/run` — build and run the server locally
- `script/clean` — remove build artifacts
There is deliberately no `hooks` target here: the repo has exactly one
pre-commit hook installer, the root `script/install-precommit`, and the hook it
installs runs the root `script/check`, which gates both halves of the repo.
There is no `docker` target either: `Dockerfile.backend` lives at the repo root
and builds with the repo root as its context, so the backend image is built by
the root `make docker` and by `script/cibuild`.
## Rationale
The NetWatch frontend collects latency measurements from the browser but has no

27
backend/script/build Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/sh
# script/build: compile the netwatch-server binary into the backend
# project root. Version and architecture are stamped into the binary via
# ldflags; on Linux the binary is statically linked.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
BINARY="netwatch-server"
main() {
cd "$ROOT"
# git describe fails outside a working repo (e.g. a source tarball),
# which must not abort the build.
version="$(git describe --always --dirty 2>/dev/null || echo unknown)"
buildarch="$(uname -m)"
ldflags="-X main.Version=$version -X main.Buildarch=$buildarch"
if [ "$(uname -s)" != "Darwin" ]; then
ldflags="-linkmode external -extldflags -static $ldflags"
fi
go build -o "$BINARY" -ldflags "$ldflags" ./cmd/netwatch-server/
}
main "$@"

16
backend/script/check Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/check: run all backend checks (test, lint, fmt-check). Must not
# modify any files. The root script/check calls this, so the repo-wide
# gate covers the backend.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/test"
"$ROOT/script/lint"
"$ROOT/script/fmt-check"
}
main "$@"

12
backend/script/clean Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/clean: remove build artifacts.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
rm -f netwatch-server
}
main "$@"

12
backend/script/fmt Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/fmt: format all Go sources in the backend (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go fmt ./...
}
main "$@"

18
backend/script/fmt-check Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# script/fmt-check: check Go formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "Files not formatted:"
echo "$unformatted"
exit 1
fi
}
main "$@"

56
backend/script/lint Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/sh
# script/lint: run the Go linter over the backend.
#
# .golangci.yml is standardized org-wide and must never be edited here
# (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with
# v1 keys, which left every threshold in the file inert while the build
# stayed green. This script therefore asserts the file still matches the
# pinned copy byte for byte before the linter runs. The check is a local
# hash comparison: no network, no remote schema, nothing unpinned in the
# build path.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# sha256 of the pinned backend/.golangci.yml.
#
# PROVISIONAL. The hash below is the file currently on main, which is
# the schema-invalid v1-keyed config described above: it is pinned only
# so this branch and main stay green, NOT because it is canonical.
#
# The canonical org-wide .golangci.yml is
# 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb,
# and PR #31 replaces backend/.golangci.yml with it. When #31 lands,
# GOLANGCI_CONFIG_SHA256 must be updated to that hash in the same
# commit. Until then, do not treat the pinned file as the standard.
GOLANGCI_CONFIG_SHA256="33ba2bf7fe4a44779d09b0fb31d6daf03685f8dc9d2bc417f963d7aabb0d17dc"
# sha256 <file>: print the file's sha256, coreutils or Darwin/busybox.
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
else
shasum -a 256 "$1" | cut -d' ' -f1
fi
}
check_config_hash() {
actual="$(sha256 .golangci.yml)"
if [ "$actual" != "$GOLANGCI_CONFIG_SHA256" ]; then
echo ".golangci.yml has drifted from the pinned config."
echo " expected $GOLANGCI_CONFIG_SHA256"
echo " actual $actual"
echo "Restore it verbatim from sneak/prompts; do not edit it."
echo "Only update GOLANGCI_CONFIG_SHA256 in this script when the"
echo "pinned config is deliberately replaced with a new standard."
exit 1
fi
}
main() {
cd "$ROOT"
check_config_hash
golangci-lint run ./...
}
main "$@"

13
backend/script/run Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/run: build and run netwatch-server locally.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/build"
exec ./netwatch-server "$@"
}
main "$@"

12
backend/script/test Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/test: run the backend test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
timeout 30 go test ./...
}
main "$@"

606
script/bootstrap Executable file
View File

@@ -0,0 +1,606 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh). Go is used directly only if its
# version falls inside the pinned window described at GO_MAX_MINOR below
# -- a newer Go is ignored, not preferred -- and golangci-lint is
# installed at the exact pinned version; both come from hash-verified
# official release archives, never an install script.
#
# The backend's toolchain is bootstrapped here because script/check runs
# backend/script/test and backend/script/lint, so a machine that cannot
# run go and golangci-lint cannot pass the repo-wide gate or the
# pre-commit hook that script/setup installs.
#
# Anything installed outside the system package manager is symlinked
# into ~/.local/bin, so a later `make check` in a plain shell finds it.
# nvm only puts node on PATH for shells that source nvm.sh, which
# neither make nor the git hook does.
#
# Three rules govern what this script is allowed to touch:
#
# 1. Everything it installs itself lands under $HOME, using $TMPDIR
# only for scratch downloads it then deletes. The one exception is
# the system package manager, which it shells out to for base
# tooling (see pkg_install) and which owns those paths already. A
# per-repo bootstrap has no business writing to /usr/local/bin, a
# Homebrew prefix, or any other system-wide location behind that
# package manager's back.
# 2. It never replaces something it did not create. Only a symlink
# that already points into one of its own managed directories is
# overwritten; anything else is left alone and bootstrap exits
# non-zero telling you what to remove.
# 3. It never reports success while the tools a later `make check`
# would pick up are not the ones it provisioned. If it cannot
# guarantee the pinned toolchain wins on your PATH, it exits
# non-zero rather than leaving you a green bootstrap and a broken
# gate.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# The PATH as the caller had it, captured before ensure_bin_dir amends
# it. verify_toolchain checks against this rather than against the PATH
# this script builds for itself, so what it reports is what a later
# `make check` in the user's own shell will actually resolve.
ORIG_PATH="$PATH"
# Pinned versions, 2026-07-07
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
# Go 1.25.7 (2026-08-09). This is the toolchain inside the pinned
# golang:1.25-alpine builder of Dockerfile.backend, so a local build
# uses the same compiler CI does.
#
# The Go pin is a compatibility constraint to match, not a floor to
# clear. golangci-lint links go/types from the Go release it was built
# with, and go/types refuses to load packages compiled by a newer Go:
# with the pinned linter (built with go1.25.4) and a host Go 1.26,
# `make check` dies with
#
# panic: file requires newer Go version go1.26
# (application built with go1.25)
#
# So an already-installed go is reused only inside a window:
# GO_MIN_VERSION is the floor from backend/go.mod, and GO_MAX_MINOR is
# the major.minor of the Go the pinned golangci-lint was built with.
# Anything outside that window is ignored and GO_VERSION is installed
# instead. GO_MAX_MINOR is therefore coupled to GOLANGCI_LINT_VERSION
# below and must be revisited whenever that pin moves; `golangci-lint
# version` prints the "built with goX.Y.Z" it needs.
GO_VERSION="1.25.7"
GO_MIN_VERSION="1.25.5"
GO_MAX_MINOR="1.25"
# golangci-lint 2.7.2 (2026-08-09). MUST stay equal to the golangci-lint
# pinned in Dockerfile.backend (currently commit
# 9f61b0f53f80672872fced07b6874397c3ed197b, which is tag v2.7.2), so a
# local `make lint` and CI's in-image `make check` report the same
# findings.
#
# Reconciliation note: PR #31 moves Dockerfile.backend to golangci-lint
# v2.12.2, commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5. When that
# lands, GOLANGCI_LINT_VERSION and every hash in golangci_lint_sha256()
# below must be updated to the v2.12.2 release archives in the same
# commit, or local and CI will disagree. GO_MAX_MINOR must move with
# it, to the major.minor that release reports as "built with".
GOLANGCI_LINT_VERSION="2.7.2"
# Where hash-verified archives are unpacked. Version-scoped, so bumping
# a pin installs alongside the old copy instead of half-overwriting it.
# Filled in by main() from script/projectname. TOOLCHAIN is also the
# ownership boundary used by link_bin: a symlink pointing inside it is
# one this script created and may replace.
TOOLCHAIN=""
GO_DIR=""
GOLANGCI_LINT_DIR=""
PKGMGR=""
SUDO=""
APT_UPDATED=""
BIN_DIR=""
NODE_BIN=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt)
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# fetch_verified <url> <expected-hash> <dest>: download a release
# archive and check it against a hash hardcoded in this script before
# anything is unpacked or run. Never pipe a remote script to a shell.
fetch_verified() {
if missing curl; then pkg_install curl curl curl curl; fi
curl -fsSL -o "$3" "$1"
verify_sha256 "$3" "$2"
}
# platform: <os>-<arch> as used in the Go and golangci-lint release
# archive filenames.
platform() {
plat_os="$(uname -s)"
plat_arch="$(uname -m)"
case "$plat_os" in
Linux) plat_os="linux" ;;
Darwin) plat_os="darwin" ;;
*)
echo "bootstrap: unsupported OS $plat_os" >&2
exit 1
;;
esac
case "$plat_arch" in
x86_64 | amd64) plat_arch="amd64" ;;
aarch64 | arm64) plat_arch="arm64" ;;
*)
echo "bootstrap: unsupported architecture $plat_arch" >&2
exit 1
;;
esac
echo "$plat_os-$plat_arch"
}
# ver_ge <have> <want>: succeed if dotted version <have> is at least
# <want>, comparing up to three numeric components.
ver_ge() {
awk -v have="$1" -v want="$2" '
BEGIN {
n = split(have, h, ".")
m = split(want, w, ".")
for (i = 1; i <= 3; i++) {
hv = (i <= n) ? h[i] + 0 : 0
wv = (i <= m) ? w[i] + 0 : 0
if (hv > wv) exit 0
if (hv < wv) exit 1
}
exit 0
}'
}
# ensure_bin_dir: the directory provisioned tools are linked into. It is
# always ~/.local/bin: per-user, never a system-wide or package-manager
# prefix. It is put at the front of PATH for the rest of this run, and
# reported if the caller's own PATH did not already contain it.
ensure_bin_dir() {
[ -n "$BIN_DIR" ] && return 0
BIN_DIR="$HOME/.local/bin"
mkdir -p "$BIN_DIR"
case "$PATH" in
"$BIN_DIR":*) ;;
*)
PATH="$BIN_DIR:$PATH"
export PATH
;;
esac
case ":$ORIG_PATH:" in
*":$BIN_DIR:"*) ;;
*)
echo "bootstrap: add $BIN_DIR to the front of your PATH, e.g." >&2
echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" >&2
;;
esac
}
# owned_path <path>: true when <path> lies inside a directory this
# script provisions, i.e. a link to it is one this script created and
# may replace. Everything else belongs to the user or to a package
# manager and is never touched.
owned_path() {
case "$1" in
"$TOOLCHAIN"/*) return 0 ;;
"$HOME"/.nvm/*) return 0 ;;
*) return 1 ;;
esac
}
# refuse_clobber <path> <what>: report that <path> is not ours and stop.
refuse_clobber() {
echo "bootstrap: $1 already exists and $2." >&2
echo " Refusing to replace something this script did not create." >&2
echo " Remove or rename it and re-run bootstrap." >&2
exit 1
}
# link_bin <target> <name>: idempotently expose one provisioned binary
# on PATH. Only an existing symlink into one of our own directories is
# replaced; a regular file, a directory, or a symlink pointing anywhere
# else is left intact and bootstrap fails.
link_bin() {
ensure_bin_dir
link="$BIN_DIR/$2"
if [ -L "$link" ]; then
existing="$(readlink "$link")"
if ! owned_path "$existing"; then
refuse_clobber "$link" \
"is a symlink to $existing, outside this repo's toolchain"
fi
elif [ -e "$link" ]; then
refuse_clobber "$link" "is not a symlink"
fi
ln -sfn "$1" "$link"
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
fetch_verified \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz" \
"$NVM_SHA256" "$tmp/nvm.tar.gz"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
NODE_BIN="$HOME/.nvm/versions/node/v$NODE_VERSION/bin"
for nb in node npm npx corepack; do
if [ -e "$NODE_BIN/$nb" ]; then link_bin "$NODE_BIN/$nb" "$nb"; fi
done
}
# ensure_yarn: yarn comes from corepack. Left to itself, `corepack
# enable` writes its shims next to the corepack binary it resolved, and
# it writes four of them (yarn, yarnpkg, pnpm, pnpx), not the one asked
# for. --install-directory keeps all four inside this repo's own
# toolchain directory, and only yarn is then linked onto PATH. The
# no-corepack fallback likewise installs into a per-user npm prefix
# under the toolchain directory instead of npm's global one. Nothing
# here writes outside $HOME.
ensure_yarn() {
if ! missing yarn; then return 0; fi
yarn_bin="$TOOLCHAIN/corepack-shims"
mkdir -p "$yarn_bin"
if ! missing corepack; then
corepack enable --install-directory "$yarn_bin"
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && \
corepack enable --install-directory \"$yarn_bin\" && \
corepack prepare yarn@$YARN_VERSION --activate"
else
yarn_bin="$TOOLCHAIN/npm-global/bin"
npm install -g --prefix "$TOOLCHAIN/npm-global" "yarn@$YARN_VERSION"
fi
if [ -e "$yarn_bin/yarn" ]; then
link_bin "$yarn_bin/yarn" yarn
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
# go_sha256 <platform>: sha256 of
# https://go.dev/dl/go1.25.7.<platform>.tar.gz, from the signed release
# index at https://go.dev/dl/?mode=json (2026-08-09).
go_sha256() {
case "$1" in
linux-amd64)
echo "12e6d6a191091ae27dc31f6efc630e3a3b8ba409baf3573d955b196fdf086005"
;;
linux-arm64)
echo "ba611a53534135a81067240eff9508cd7e256c560edd5d8c2fef54f083c07129"
;;
darwin-amd64)
echo "bf5050a2152f4053837b886e8d9640c829dbacbc3370f913351eb0904cb706f5"
;;
darwin-arm64)
echo "ff18369ffad05c57d5bed888b660b31385f3c913670a83ef557cdfd98ea9ae1b"
;;
*)
echo "bootstrap: no pinned Go archive hash for $1" >&2
exit 1
;;
esac
}
# go_ok: an already-installed go is acceptable only inside the window
# described at GO_MAX_MINOR: at least GO_MIN_VERSION, and no newer in
# major.minor than the Go the pinned golangci-lint was built with. A
# newer host Go is not "good enough", it makes `make check` panic, so it
# is treated exactly like a missing one.
go_ok() {
if missing go; then return 1; fi
have="$(go version 2>/dev/null | awk '{print $3}')"
have="${have#go}"
[ -n "$have" ] || return 1
ver_ge "$have" "$GO_MIN_VERSION" || return 1
ver_ge "$GO_MAX_MINOR" "$(echo "$have" | cut -d. -f1,2)"
}
# gofmt_ok: gofmt is a gate tool -- backend/script/fmt-check runs it --
# and its output is not guaranteed byte-identical across Go releases, so
# a gofmt from a different release than the go that compiles the code is
# treated exactly like a missing one, the same way a mismatched
# golangci-lint is. `go version <file>` prints the toolchain a Go binary
# was built with, so this compares the gofmt that resolves on PATH
# against the go that resolves on PATH, without depending on where
# either one lives. Anything it cannot read -- no go to ask, gofmt
# absent, not a Go binary -- fails closed.
gofmt_ok() {
if missing go; then return 1; fi
if missing gofmt; then return 1; fi
go_have="$(go version 2>/dev/null | awk '{print $3}')"
[ -n "$go_have" ] || return 1
fmt_have="$(go version "$(command -v gofmt)" 2>/dev/null | awk '{print $NF}')"
[ "$fmt_have" = "$go_have" ]
}
# ensure_go: reuse the host toolchain only when the go on PATH is inside
# the window AND a gofmt from that same release is on PATH with it. Both
# link_bin calls sit outside that early return, so whenever the pinned
# toolchain is the one in use they run on every bootstrap, not only on
# the run that unpacked the archive: a deleted or never-created gofmt
# link is restored rather than silently left to some other Go's gofmt.
ensure_go() {
if go_ok && gofmt_ok; then return 0; fi
if [ ! -x "$GO_DIR/bin/go" ]; then
plat="$(platform)"
tmp="$(mktemp -d)"
fetch_verified \
"https://go.dev/dl/go${GO_VERSION}.${plat}.tar.gz" \
"$(go_sha256 "$plat")" "$tmp/go.tar.gz"
rm -rf "$GO_DIR.partial"
mkdir -p "$GO_DIR.partial"
tar -xzf "$tmp/go.tar.gz" -C "$GO_DIR.partial" --strip-components=1
rm -rf "$GO_DIR"
mv "$GO_DIR.partial" "$GO_DIR"
rm -rf "$tmp"
fi
link_bin "$GO_DIR/bin/go" go
link_bin "$GO_DIR/bin/gofmt" gofmt
}
# golangci_lint_sha256 <platform>: sha256 of the golangci-lint 2.7.2
# release archive for that platform, from
# https://github.com/golangci/golangci-lint/releases/download/v2.7.2/golangci-lint-2.7.2-checksums.txt
# (2026-08-09).
golangci_lint_sha256() {
case "$1" in
linux-amd64)
echo "ce46a1f1d890e7b667259f70bb236297f5cf8791a9b6b98b41b283d93b5b6e88"
;;
linux-arm64)
echo "7028e810837722683dab679fb121336cfa303fecff39dfe248e3e36bc18d941b"
;;
darwin-amd64)
echo "6966554840a02229a14c52641bc38c2c7a14d396f4c59ba0c7c8bb0675ca25c9"
;;
darwin-arm64)
echo "6ce86a00e22b3709f7b994838659c322fdc9eae09e263db50439ad4f6ec5785c"
;;
*)
echo "bootstrap: no pinned golangci-lint archive hash for $1" >&2
exit 1
;;
esac
}
# golangci_lint_ok: unlike go, this must be the exact pinned version.
# A different version reports a different set of findings, so local
# results would stop matching what Dockerfile.backend gates on.
golangci_lint_ok() {
if missing golangci-lint; then return 1; fi
have="$(golangci-lint version 2>&1 | awk '
{
for (i = 1; i < NF; i++) {
if ($i == "version") {
v = $(i + 1)
sub(/^v/, "", v)
print v
exit
}
}
}')"
[ "$have" = "$GOLANGCI_LINT_VERSION" ]
}
ensure_golangci_lint() {
if golangci_lint_ok; then return 0; fi
if [ ! -x "$GOLANGCI_LINT_DIR/golangci-lint" ]; then
plat="$(platform)"
base="golangci-lint-${GOLANGCI_LINT_VERSION}-${plat}"
tmp="$(mktemp -d)"
fetch_verified \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${base}.tar.gz" \
"$(golangci_lint_sha256 "$plat")" "$tmp/golangci-lint.tar.gz"
mkdir -p "$tmp/x"
tar -xzf "$tmp/golangci-lint.tar.gz" -C "$tmp/x" --strip-components=1
rm -rf "$GOLANGCI_LINT_DIR.partial"
mkdir -p "$GOLANGCI_LINT_DIR.partial"
cp "$tmp/x/golangci-lint" "$GOLANGCI_LINT_DIR.partial/golangci-lint"
chmod +x "$GOLANGCI_LINT_DIR.partial/golangci-lint"
rm -rf "$GOLANGCI_LINT_DIR"
mv "$GOLANGCI_LINT_DIR.partial" "$GOLANGCI_LINT_DIR"
rm -rf "$tmp"
fi
link_bin "$GOLANGCI_LINT_DIR/golangci-lint" golangci-lint
}
# verify_toolchain: bootstrap must not exit 0 while the tools the gate
# will actually run are not the provisioned ones. Everything above only
# guarantees the right tools exist and are linked into $BIN_DIR; if
# something earlier on the caller's PATH shadows them, `make check` --
# and the pre-commit hook script/setup installs -- still break, and a
# warning buried in a long bootstrap log is not enough. So the checks
# re-run against the PATH the caller will have (theirs, plus $BIN_DIR at
# the front if bootstrap had to ask for it), and a failure is fatal.
#
# Every gate tool that has a version constraint is checked with the same
# predicate its install used -- go_ok, gofmt_ok, golangci_lint_ok -- not
# with a bare presence test, because a wrong-version gate tool produces
# different results from the one CI runs, which is the failure this
# function exists to prevent. node and yarn have no pinned version to
# disagree about, so presence is the whole constraint for them.
verify_toolchain() {
# BIN_DIR is only set once something needed linking, but the remedy
# text must name a real directory in every reachable state, so fall
# back to the one ensure_bin_dir would have chosen.
bin_dir="${BIN_DIR:-$HOME/.local/bin}"
# Model the PATH the caller will actually have: their own, plus
# $BIN_DIR at the front only if bootstrap linked something there and
# therefore told them to add it.
verify_path="$ORIG_PATH"
if [ -n "$BIN_DIR" ]; then
case ":$ORIG_PATH:" in
*":$BIN_DIR:"*) ;;
*) verify_path="$BIN_DIR:$ORIG_PATH" ;;
esac
fi
saved_path="$PATH"
PATH="$verify_path"
export PATH
bad=""
go_ok || bad="$bad go"
gofmt_ok || bad="$bad gofmt"
golangci_lint_ok || bad="$bad golangci-lint"
for t in node yarn; do
if missing "$t"; then bad="$bad $t"; fi
done
PATH="$saved_path"
export PATH
[ -z "$bad" ] && return 0
# Two different faults land here and they need different remedies: a
# tool that resolves but is the wrong build is being shadowed, and
# telling the user to fix PATH is right; a tool that does not resolve
# at all is not being shadowed by anything, and saying so would send
# them hunting for a conflict that does not exist.
echo "bootstrap: the toolchain on your PATH cannot run the gate." >&2
wrong=""
absent=""
for t in $bad; do
where="$(
export PATH="$verify_path"
command -v "$t" 2>/dev/null || true
)"
if [ -n "$where" ]; then
echo " $t: $where (wrong version)" >&2
wrong="$wrong $t"
else
echo " $t: not found" >&2
absent="$absent $t"
fi
done
echo " The pinned toolchain is linked into $bin_dir." >&2
if [ -n "$wrong" ]; then
echo " The tools shown with a path resolve to a build this" >&2
echo " script did not provision: something earlier on your PATH" >&2
echo " shadows $bin_dir. Put $bin_dir first in" >&2
echo " PATH, or remove the conflicting tool, then re-run." >&2
fi
if [ -n "$absent" ]; then
echo " The tools shown as not found are on no directory of your" >&2
echo " PATH at all, so nothing is shadowing them. Add $bin_dir" >&2
echo " to PATH and re-run; if they are still not found after" >&2
echo " that, bootstrap failed to install them and that is a bug" >&2
echo " in this script, not in your environment." >&2
fi
echo " Failing rather than leaving you a bootstrap that reports" >&2
echo " success and a \`make check\` that does not run." >&2
exit 1
}
main() {
cd "$ROOT"
TOOLCHAIN="$HOME/.local/share/$("$ROOT/script/projectname")/toolchain"
GO_DIR="$TOOLCHAIN/go-$GO_VERSION"
GOLANGCI_LINT_DIR="$TOOLCHAIN/golangci-lint-$GOLANGCI_LINT_VERSION"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
ensure_node
ensure_yarn
install_js_deps
ensure_go
ensure_golangci_lint
verify_toolchain
echo "bootstrap complete"
}
main "$@"

16
script/check Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check) across the whole
# repo, frontend and backend. Our own extension to
# scripts-to-rule-them-all. Must not modify any files.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/test"
"$ROOT/script/lint"
"$ROOT/script/fmt-check"
}
main "$@"

24
script/cibuild Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/sh
# script/cibuild: run the CI build. It builds every image in the repo:
# the frontend image from Dockerfile and the backend image from
# Dockerfile.backend. Each Dockerfile runs its half of make check as a
# build step, so a successful cibuild implies the whole repo is green.
# This is the only build step the Gitea workflow runs.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# build_image <dockerfile>: build one image from the repo root context.
# Every docker build CI performs goes through here, so build-wide flags
# only ever have to be added in one place.
build_image() {
timeout 300 docker build -f "$1" .
}
main() {
cd "$ROOT"
build_image Dockerfile
build_image Dockerfile.backend
}
main "$@"

16
script/docker Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/docker: build the repo's Docker images, tagged from
# script/projectname: the frontend image as <name> and the backend image
# as <name>-server. Both build from the repo root as their context.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
name="$("$ROOT/script/projectname")"
timeout 300 docker build -t "$name" -f Dockerfile .
timeout 300 docker build -t "$name-server" -f Dockerfile.backend .
}
main "$@"

14
script/fmt Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/fmt: format the whole repo (writes): prettier over everything
# it understands, then gofmt over the Go backend.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/frontend-fmt"
"$ROOT/backend/script/fmt"
}
main "$@"

14
script/fmt-check Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/fmt-check: check formatting across the whole repo (read-only).
# Same scope as script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/frontend-fmt-check"
"$ROOT/backend/script/fmt-check"
}
main "$@"

18
script/frontend-check Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# script/frontend-check: run the frontend half of the checks only (test,
# lint, fmt-check). This exists for the frontend Dockerfile, whose build
# stage is a node image with no Go toolchain; the backend half is gated
# by Dockerfile.backend. Everywhere else, use script/check, which covers
# the whole repo. Must not modify any files.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/frontend-test"
"$ROOT/script/frontend-lint"
"$ROOT/script/frontend-fmt-check"
}
main "$@"

14
script/frontend-fmt Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/frontend-fmt: format the frontend and every other file prettier
# understands, repo-wide (writes). backend/ is in .prettierignore; Go
# sources are formatted by backend/script/fmt.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn prettier --write .
}
main "$@"

13
script/frontend-fmt-check Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/frontend-fmt-check: check prettier formatting (read-only). Same
# scope as script/frontend-fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn prettier --check .
}
main "$@"

12
script/frontend-lint Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/frontend-lint: run the frontend linter (prettier in check mode).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn prettier --check .
}
main "$@"

14
script/frontend-test Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/frontend-test: run the frontend test suite. The frontend has no
# unit tests; the production build serves as the test (fails on broken
# code).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
timeout 30 yarn build
}
main "$@"

16
script/install-precommit Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

14
script/lint Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/lint: lint the whole repo: prettier over everything it
# understands, then golangci-lint over the Go backend.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/frontend-lint"
"$ROOT/backend/script/lint"
}
main "$@"

13
script/precommit Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "netwatch"
}
main "$@"

14
script/setup Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/bootstrap"
"$ROOT/script/install-precommit"
}
main "$@"

14
script/test Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/test: run the test suite for the whole repo: the frontend at
# the repo root, then the Go backend in backend/.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
"$ROOT/script/frontend-test"
"$ROOT/backend/script/test"
}
main "$@"

View File

@@ -551,9 +551,9 @@ function hostRowHTML(host, index, showPin = true) {
: `<div class="flex-shrink-0 w-4 h-4"></div>`;
return `
<div class="host-row bg-gray-800/50 rounded-lg p-2 border border-gray-700/50" data-index="${index}">
<div class="host-row-inner flex items-center gap-4 min-w-0">
<div class="flex items-center gap-4 min-w-0">
${pinBtn}
<div class="host-info-panel w-[420px] flex-shrink-0 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div class="w-[420px] flex-shrink-0 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div class="flex items-center gap-2 min-w-[200px]">
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: ${latencyHex(null)}"></div>
<span class="font-medium text-white truncate">${host.name}</span>
@@ -589,9 +589,9 @@ function buildUI(state) {
app.innerHTML = `
<div class="mx-auto px-[5%] py-8">
<header class="mb-8">
<div class="header-top flex items-center justify-between mb-4">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white"><a href="https://git.eeqj.de/sneak/netwatch" target="_blank" rel="noopener" class="underline decoration-dashed decoration-gray-500 underline-offset-4">NetWatch</a> by <a href="https://sneak.berlin" target="_blank" rel="noopener" class="text-blue-400 underline hover:text-blue-300">@sneak</a></h1>
<div class="header-controls flex flex-col items-end gap-2">
<div class="flex flex-col items-end gap-2">
<button id="pause-btn"
class="flex items-center gap-3 px-6 py-3 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500">
<svg id="pause-icon" class="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">

View File

@@ -22,53 +22,95 @@ body {
);
}
/* Mobile-responsive host rows */
/* ---- Mobile responsive layout (portrait / narrow viewports) ---- */
@media (max-width: 768px) {
.host-row-inner {
flex-direction: column !important;
align-items: stretch !important;
gap: 0.5rem !important;
/* Header: stack title and controls vertically */
header .flex.items-center.justify-between {
flex-direction: column;
align-items: flex-start !important;
gap: 1rem;
}
.host-row-inner .pin-btn {
align-self: flex-end;
header .flex.flex-col.items-end {
align-items: flex-start !important;
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
}
.host-info-panel {
width: 100% !important;
/* Pause button: smaller on mobile */
#pause-btn {
padding: 0.5rem 1rem;
}
.host-row .sparkline-container {
width: 100% !important;
flex-shrink: 0;
#pause-btn svg {
width: 1.25rem;
height: 1.25rem;
}
/* Summary line: allow wrapping */
#pause-text {
font-size: 0.875rem;
}
/* Summary box: wrap into a grid for readability */
#summary {
display: flex !important;
flex-wrap: wrap !important;
gap: 0.25rem 0.5rem !important;
justify-content: center !important;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
justify-content: center;
line-height: 1.6;
}
/* Hide the pipe separators on mobile */
#summary .text-gray-600.mx-3 {
display: none;
}
/* Header: stack controls below title */
.header-top {
flex-direction: column !important;
align-items: flex-start !important;
gap: 1rem !important;
/* Host row: stack vertically */
.host-row .flex.items-center.gap-4 {
flex-direction: column;
align-items: stretch !important;
gap: 0.5rem;
}
.header-controls {
align-items: flex-start !important;
width: 100%;
/* Info section: full width, remove fixed width */
.host-row .w-\[420px\] {
width: 100% !important;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
}
#pause-btn {
/* Host name row with dot */
.host-row .flex.items-center.gap-2.min-w-\[200px\] {
min-width: 0;
}
/* Latency value: slightly smaller on mobile */
.host-row .latency-value {
font-size: 1.875rem;
line-height: 2.25rem;
}
/* Sparkline: full width below the info */
.host-row .sparkline-container {
width: 100%;
justify-content: center;
flex-shrink: 0;
}
/* Pin button: inline with the host info */
.host-row .pin-btn {
position: absolute;
right: 0.5rem;
top: 0.5rem;
}
.host-row {
position: relative;
}
/* Footer legend: wrap nicely */
footer p {
line-height: 1.8;
}
}