diff --git a/.prettierignore b/.prettierignore index d87c685..c9cb794 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,5 @@ +node_modules/ +yarn.lock + # Vendored, minified third-party bundles must never be reformatted. *.min.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 6f7c217..53bc824 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -1,6 +1,8 @@ # Go HTTP Server Conventions -This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards. +This document defines the architectural patterns, design decisions, and +conventions for building Go HTTP servers. All new projects must follow these +standards. ## Table of Contents @@ -25,18 +27,18 @@ This document defines the architectural patterns, design decisions, and conventi These libraries are **mandatory** for all new projects: -| Purpose | Library | Import Path | -|---------|---------|-------------| -| Dependency Injection | Uber fx | `go.uber.org/fx` | -| HTTP Router | go-chi | `github.com/go-chi/chi` | -| Logging | slog (stdlib) | `log/slog` | -| Configuration | Viper | `github.com/spf13/viper` | -| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` | -| CORS | go-chi/cors | `github.com/go-chi/cors` | -| Error Reporting | Sentry | `github.com/getsentry/sentry-go` | -| Metrics | Prometheus | `github.com/prometheus/client_golang` | -| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` | -| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` | +| Purpose | Library | Import Path | +| -------------------- | --------------- | ------------------------------------- | +| Dependency Injection | Uber fx | `go.uber.org/fx` | +| HTTP Router | go-chi | `github.com/go-chi/chi` | +| Logging | slog (stdlib) | `log/slog` | +| Configuration | Viper | `github.com/spf13/viper` | +| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` | +| CORS | go-chi/cors | `github.com/go-chi/cors` | +| Error Reporting | Sentry | `github.com/getsentry/sentry-go` | +| Metrics | Prometheus | `github.com/prometheus/client_golang` | +| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` | +| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` | --- @@ -85,7 +87,8 @@ project-root/ ### Key Principles - **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. -- **`internal/`**: All application packages. Not importable by external projects. +- **`internal/`**: All application packages. Not importable by external + projects. - **One package per concern**: config, database, handlers, middleware, etc. - **Flat handler files**: One file per handler or logical group of handlers. @@ -190,7 +193,8 @@ Providers are resolved automatically by fx, but conceptually follow this order: 2. `logger.New` - Logger (depends on Globals) 3. `config.New` - Configuration (depends on Globals, Logger) 4. `database.New` - Database (depends on Logger, Config) -5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database) +5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, + Database) 6. `middleware.New` - Middleware (depends on Logger, Globals, Config) 7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck) 8. `server.New` - Server (depends on all above) @@ -453,7 +457,8 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { ### Closure-Based Handler Pattern -All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created: +All handlers return `http.HandlerFunc` using the closure pattern. This allows +initialization logic to run once when the handler is created: ```go // internal/handlers/index.go @@ -510,7 +515,8 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac ### Handler Naming Convention - `HandleIndex()` - Main page -- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix +- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method + suffix - `HandleNow()` - API endpoints - `HandleHealthCheck()` - System endpoints @@ -733,7 +739,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { 1. **Environment variables** (highest priority via `AutomaticEnv()`) 2. **`.env` file** (loaded via `godotenv/autoload` import) -3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml` +3. **Config files**: `/etc/{appname}/{appname}.yaml`, + `~/.config/{appname}/{appname}.yaml` 4. **Defaults** (lowest priority) ### Environment Loading @@ -1005,6 +1012,7 @@ var Static embed.FS ``` Directory structure: + ``` static/ ├── static.go @@ -1045,15 +1053,13 @@ Templates use Go's template composition: ```html -{{ template "htmlheader.html" . }} -{{ template "navbar.html" . }} +{{ template "htmlheader.html" . }} {{ template "navbar.html" . }}
-{{ template "pagefooter.html" . }} -{{ template "htmlfooter.html" . }} +{{ template "pagefooter.html" . }} {{ template "htmlfooter.html" . }} ``` ### Static Asset References @@ -1214,12 +1220,12 @@ if viper.GetString("METRICS_USERNAME") != "" { ### Environment Variables Summary -| Variable | Description | Default | -|----------|-------------|---------| -| `PORT` | HTTP listen port | 8080 | -| `DEBUG` | Enable debug logging | false | -| `DBURL` | Database connection URL | "" | -| `SENTRY_DSN` | Sentry DSN for error reporting | "" | -| `MAINTENANCE_MODE` | Enable maintenance mode | false | -| `METRICS_USERNAME` | Basic auth username for /metrics | "" | -| `METRICS_PASSWORD` | Basic auth password for /metrics | "" | +| Variable | Description | Default | +| ------------------ | -------------------------------- | ------- | +| `PORT` | HTTP listen port | 8080 | +| `DEBUG` | Enable debug logging | false | +| `DBURL` | Database connection URL | "" | +| `SENTRY_DSN` | Sentry DSN for error reporting | "" | +| `MAINTENANCE_MODE` | Enable maintenance mode | false | +| `METRICS_USERNAME` | Basic auth username for /metrics | "" | +| `METRICS_PASSWORD` | Basic auth password for /metrics | "" | diff --git a/README.md b/README.md index 09ea48f..a7488e3 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # µPaaS by [@sneak](https://sneak.berlin) -A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab. +A simple self-hosted PaaS that auto-deploys Docker containers from Git +repositories via webhooks from Gitea, GitHub, or GitLab. ## Features - Single admin user with argon2id password hashing - Per-app SSH keypairs for read-only deploy keys -- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab +- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and + GitLab - Branch filtering - only deploy on configured branch changes - Environment variables, labels, and volume mounts per app - CPU and memory resource limits per app @@ -95,9 +97,12 @@ chi Router ──► Middleware Stack ──► Handler ### Key Patterns -- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing one-time initialization -- **Active Record models**: Models encapsulate database operations (`Save()`, `Delete()`, `Reload()`) -- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()` +- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing + one-time initialization +- **Active Record models**: Models encapsulate database operations (`Save()`, + `Delete()`, `Reload()`) +- **Async deployments**: Webhook triggers deploy via goroutine with + `context.WithoutCancel()` - **Embedded assets**: Templates and static files embedded via `//go:embed` ## Entrypoints @@ -105,12 +110,12 @@ chi Router ──► Middleware Stack ──► Handler 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. We provide: +development workflow, and the Makefile targets are thin shims that call them. We +provide: - `script/bootstrap` — install all dependencies (idempotent) -- `script/setup` — make a fresh clone ready for development - (bootstrap, then install-precommit) +- `script/setup` — make a fresh clone ready for development (bootstrap, then + install-precommit) - `script/projectname` — output the project name ("upaas") - `script/test` — run the test suite - `script/lint` — run golangci-lint @@ -118,12 +123,12 @@ them. We provide: - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check - `script/docker` — build the Docker image tagged via `script/projectname` -- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile - runs the checks, so a green build implies a green repo) +- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the + checks, so a green build implies a green repo) - `script/precommit` — pre-commit checks (`go mod tidy` guard, then `script/check`) -- `script/install-precommit` — install the git pre-commit hook that - runs `script/precommit` +- `script/install-precommit` — install the git pre-commit hook that runs + `script/precommit` ## Development @@ -156,11 +161,11 @@ Before every commit: 1. **Format**: Run `make fmt` to format all code 2. **Lint**: Run `make lint` and fix all errors/warnings - - Do not disable linters or add nolint comments without good reason - - Fix the code, don't hide the problem + - Do not disable linters or add nolint comments without good reason + - Fix the code, don't hide the problem 3. **Test**: Run `make test` and ensure all tests pass - - Fix failing tests by fixing the code, not by modifying tests to pass - - Add tests for new functionality + - Fix failing tests by fixing the code, not by modifying tests to pass + - Add tests for new functionality 4. **Verify**: Run `make check` to confirm everything passes ```bash @@ -174,6 +179,7 @@ git commit -m "Your message" ``` The Docker build runs `make check` and will fail if: + - Code is not formatted - Linting errors exist - Tests fail @@ -185,17 +191,17 @@ This ensures the main branch always contains clean, tested, working code. Environment variables: -| Variable | Description | Default | -|----------|-------------|---------| -| `PORT` | HTTP listen port | 8080 | -| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) | -| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | *(none — must be set to an absolute path)* | -| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock | -| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false | -| `DEBUG` | Enable debug logging | false | -| `SENTRY_DSN` | Sentry error reporting DSN | "" | -| `METRICS_USERNAME` | Basic auth for /metrics | "" | -| `METRICS_PASSWORD` | Basic auth for /metrics | "" | +| Variable | Description | Default | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `PORT` | HTTP listen port | 8080 | +| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) | +| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | _(none — must be set to an absolute path)_ | +| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock | +| `UPAAS_PLAINTEXT_HTTP` | Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use `http://`. Leave unset behind a TLS-terminating reverse proxy. | false | +| `DEBUG` | Enable debug logging | false | +| `SENTRY_DSN` | Sentry error reporting DSN | "" | +| `METRICS_USERNAME` | Basic auth for /metrics | "" | +| `METRICS_PASSWORD` | Basic auth for /metrics | "" | ## Running with Docker @@ -217,35 +223,38 @@ TLS-terminating reverse proxy, drop that line. ```yaml services: - upaas: - build: . - restart: unless-stopped - ports: - - "8080:8080" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ${HOST_DATA_DIR}:/var/lib/upaas - environment: - - UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR} - # Set when serving plain HTTP (no TLS-terminating proxy); drop behind one - - UPAAS_PLAINTEXT_HTTP=true - # Optional: uncomment to enable debug logging - # - DEBUG=true - # Optional: Sentry error reporting - # - SENTRY_DSN=https://... - # Optional: Prometheus metrics auth - # - METRICS_USERNAME=prometheus - # - METRICS_PASSWORD=secret + upaas: + build: . + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${HOST_DATA_DIR}:/var/lib/upaas + environment: + - UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR} + # Set when serving plain HTTP (no TLS-terminating proxy); drop behind one + - UPAAS_PLAINTEXT_HTTP=true + # Optional: uncomment to enable debug logging + # - DEBUG=true + # Optional: Sentry error reporting + # - SENTRY_DSN=https://... + # Optional: Prometheus metrics auth + # - METRICS_USERNAME=prometheus + # - METRICS_PASSWORD=secret ``` -**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running -`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR` -so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break -container builds because the Docker daemon resolves paths relative to the host, not the container. +**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the +host before running `docker compose up`. This value is bind-mounted into the +container and passed as `UPAAS_HOST_DATA_DIR` so that Docker bind mounts during +builds resolve correctly. Relative paths (e.g. `./data`) will break container +builds because the Docker daemon resolves paths relative to the host, not the +container. Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d` -Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. +Session secrets are automatically generated on first startup and persisted to +`$UPAAS_DATA_DIR/session.key`. ## License diff --git a/TODO.md b/TODO.md index ae65dff..bf456fe 100644 --- a/TODO.md +++ b/TODO.md @@ -1,70 +1,75 @@ # 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 +- 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 -1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` -is green as of the golangci-lint v2.12.2 update. +1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. `make check` is green +as of the golangci-lint v2.12.2 update. # Next Step -Confirm `.gitea/workflows/check.yml` gates merges on `make check` so -main cannot regress. +Confirm `.gitea/workflows/check.yml` gates merges on `make check` so main cannot +regress. # Completed Steps +- 2026-09-22: Vendored the canonical prettier/format toolchain from the + `sneak/prompts` scaffold: added `.prettierrc` (tabWidth 4, proseWrap always), + pinned `package.json` + `yarn.lock` (prettier 3.8.1), taught + `script/bootstrap` to install a pinned node/yarn via a hash-verified nvm + archive, and switched `script/fmt` to the pinned prettier reading + `.prettierrc` (no inline flags) over `static/js/*.js` and `**/*.md`. Reflowed + all markdown to house style; `alpine.min.js` stays byte-identical (#203). - 2026-09-22: Fixed the flaky `t.TempDir` cleanup race in - `internal/service/webhook` by tracking the async deployment goroutine - in a `sync.WaitGroup` and exposing `WaitForDeployments`; tests now - synchronize on completion instead of sleeping (#198). -- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint` - (pinned golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg - so the linter always executes), reduced `script/lint` to building it, - dropped the golangci-lint install from `script/bootstrap`, and switched - the `Dockerfile` lint stage to invoke `golangci-lint` directly instead - of `make lint` to avoid docker-in-docker (#188). -- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites - the vendored `static/js/alpine.min.js` bundle (#185). -- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy - log download handler by verifying the resolved path stays within the - deploy log directory before serving, returning 404 on escape (#177). + `internal/service/webhook` by tracking the async deployment goroutine in a + `sync.WaitGroup` and exposing `WaitForDeployments`; tests now synchronize on + completion instead of sleeping (#198). +- 2026-09-22: Linting now runs only in Docker. Added `Dockerfile.lint` (pinned + golangci-lint v2.12.2, cache-busted via a `GATE_RUN` build arg so the linter + always executes), reduced `script/lint` to building it, dropped the + golangci-lint install from `script/bootstrap`, and switched the `Dockerfile` + lint stage to invoke `golangci-lint` directly instead of `make lint` to avoid + docker-in-docker (#188). +- 2026-09-22: Added `.prettierignore` so `make fmt` no longer rewrites the + vendored `static/js/alpine.min.js` bundle (#185). +- 2026-09-22: Fixed the gosec G703 path-traversal finding in the deploy log + download handler by verifying the resolved path stays within the deploy log + directory before serving, returning 404 on escape (#177). - 2026-09-22: `script/bootstrap` now installs a pinned `goimports` - (`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` - succeeds on a fresh machine after `make bootstrap` (#184). -- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin - check over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git - image when absent (#190), the env-var editor CSRF token lookup (#191), - and the port-mapping delete form's CSRF field (#192). -- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical - `.golangci.yml`, `Dockerfile` lint stage pin, `script/bootstrap` - release-archive pins) and fixed all resulting lint findings (noctx, - gosec, goconst, lll, dupl, nolintlint); `make check` green. -- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - Makefile shims, README Entrypoints section + (`golang.org/x/tools` v0.49.0) into `/usr/local/bin`, so `make fmt` succeeds + on a fresh machine after `make bootstrap` (#184). +- 2026-09-09: Fixed four deployability blockers found by QA: CSRF origin check + over plain HTTP (`UPAAS_PLAINTEXT_HTTP`, #189), pulling the git image when + absent (#190), the env-var editor CSRF token lookup (#191), and the + port-mapping delete form's CSRF field (#192). +- 2026-08-07: Updated golangci-lint to v2.12.2 (canonical `.golangci.yml`, + `Dockerfile` lint stage pin, `script/bootstrap` release-archive pins) and + fixed all resulting lint findings (noctx, gosec, goconst, lll, dupl, + nolintlint); `make check` green. +- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile + shims, README Entrypoints section - 2026-03-11: Monolithic env var editing with bulk save (#158). -- 2026-03-10: Webhook event history UI page (#164); added missing - Makefile docker and hooks targets plus test timeout (#159); - notification settings passed from create form (#160). +- 2026-03-10: Webhook event history UI page (#164); added missing Makefile + docker and hooks targets plus test timeout (#159); notification settings + passed from create form (#160). - 2026-03-03: REPO_POLICIES compliance file set added (#155). -- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); - Dockerfile split into lint and build stages with forced lint - execution (#152, #154). +- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143); Dockerfile + split into lint and build stages with forced lint execution (#152, #154). - 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146). -- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, - deployment log size cap, error path rendering, docker-compose bind - mount, domain type refactor. +- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error, deployment + log size cap, error path rendering, docker-compose bind mount, domain type + refactor. - CI simplified to docker build only (#130). -- 2025-12-29 onward: core PaaS built out: deploys with real-time build - log streaming, container start/stop/restart and logs, TCP/UDP port - mapping, Alpine.js UI, Slack notifications, ULID app IDs, session - handling. +- 2025-12-29 onward: core PaaS built out: deploys with real-time build log + streaming, container start/stop/restart and logs, TCP/UDP port mapping, + Alpine.js UI, Slack notifications, ULID app IDs, session handling. # Future Steps diff --git a/package.json b/package.json new file mode 100644 index 0000000..dc05cde --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "prettier": "3.8.1" + } +} diff --git a/script/bootstrap b/script/bootstrap index 095afa8..58f9160 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -5,8 +5,12 @@ # or apk (detected in that order); assumes NOTHING is present (not git, # make, or go). goimports is installed with `go install` at a pinned # version (integrity via the Go module checksum database) into -# /usr/local/bin so it is on PATH. The linter is not installed here: it -# runs only in Docker via script/lint, so docker is its sole prerequisite. +# /usr/local/bin so it is on PATH. 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), +# then the pinned prettier from yarn.lock. The linter is not installed +# here: it runs only in Docker via script/lint, so docker is its sole +# prerequisite. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -15,6 +19,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # golang.org/x/tools goimports, 2026-08-13. v0.49.0 requires Go 1.25 (matches # go.mod); v0.50.0 needs Go 1.26. Integrity via the Go module checksum database. GOIMPORTS_VERSION="v0.49.0" +# Node/yarn toolchain, 2026-07-06. +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" PKGMGR="" SUDO="" @@ -56,6 +66,21 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } +# verify_sha256 +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 +} + # goimports is not packaged uniformly across nix/apt/brew/apk, so install it # with `go install` at a pinned version and place the binary in /usr/local/bin # so it is on PATH regardless of shell config. Requires go, which main @@ -69,6 +94,54 @@ ensure_goimports() { rm -rf "$tmp" } +# 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)" + curl -fsSL -o "$tmp/nvm.tar.gz" \ + "https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz" + verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256" + 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" +} + +ensure_yarn() { + if ! missing yarn; then return 0; fi + if ! missing corepack; then + corepack enable + corepack prepare "yarn@$YARN_VERSION" --activate + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \ + corepack prepare yarn@$YARN_VERSION --activate" + else + npm install -g "yarn@$YARN_VERSION" + 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 +} + main() { cd "$ROOT" @@ -80,6 +153,11 @@ main() { if missing go; then pkg_install go golang go go; fi ensure_goimports + # Node toolchain and pinned prettier + ensure_node + ensure_yarn + install_js_deps + # The linter runs only in Docker (script/lint). Warn, don't fail: the # rest of the repo works without it. if missing docker; then diff --git a/script/fmt b/script/fmt index 2c9c55c..d7ab4f3 100755 --- a/script/fmt +++ b/script/fmt @@ -4,11 +4,34 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + yarn "$@" + return + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" gofmt -s -w . goimports -w . - npx prettier --write --tab-width 4 static/js/*.js + # Pinned prettier reads settings from .prettierrc (tabWidth 4, + # proseWrap always); .prettierignore keeps alpine.min.js untouched. + # Globs are quoted so prettier expands them, not the shell. + run_yarn run prettier --write 'static/js/*.js' '**/*.md' } main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d846639 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +prettier@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==