diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index aca7a51..ee73864 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -6,4 +6,4 @@ jobs: steps: # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - run: docker build . + - run: script/cibuild diff --git a/Dockerfile b/Dockerfile index c70f4d2..249e3f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,10 @@ # node 22-alpine, 2026-02-22 FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 -RUN apk add --no-cache make - WORKDIR /app +COPY script/ script/ COPY package.json yarn.lock ./ -RUN yarn install --frozen-lockfile +RUN script/bootstrap COPY . . RUN make check diff --git a/Makefile b/Makefile index 5db00c1..be4ba00 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,27 @@ -.PHONY: test lint fmt fmt-check check build build-bin install dev clean docker hooks - -# Use `timeout` (GNU coreutils) when available so `make test` is hard-capped. -# On macOS without coreutils this is empty and the cap is skipped. -TIMEOUT := $(shell command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null) +.PHONY: bootstrap setup test lint fmt fmt-check check build build-bin install dev clean docker hooks YARN := yarn run +bootstrap: + @script/bootstrap + +setup: + @script/setup + test: - @$(TIMEOUT) $(if $(TIMEOUT),30s,) $(YARN) vitest run --reporter=dot || \ - { echo "--- Rerunning with verbose for details ---"; \ - $(YARN) vitest run --reporter=verbose; exit 1; } + @script/test lint: - @$(YARN) eslint . - @$(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 build: @$(YARN) tsc @@ -41,13 +41,7 @@ clean: @rm -rf dist coverage .vitest-cache *.tsbuildinfo docker: - docker build -t quak . + @script/docker hooks: - @printf '#!/bin/sh\nset -e\nmake lint\nmake fmt-check\n' > .git/hooks/pre-commit - @chmod +x .git/hooks/pre-commit - @echo "Installed pre-commit hook (runs make lint && make fmt-check)." - @echo "Note: tests are deliberately not in the pre-commit hook so the" - @echo "TDD red-phase commit (failing tests, no implementation yet)" - @echo "can land. CI runs make check via docker build, which catches" - @echo "any branch that ships red." + @script/install-precommit diff --git a/README.md b/README.md index bfab26d..0762343 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,40 @@ const snapshot = client.toJSON(); const restored = Client.fromJSON(snapshot); ``` +## 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 scripts are POSIX sh (not bash) so they run in minimal containers such as +alpine. We provide: + +- `script/bootstrap` — install all dependencies (node/yarn if missing, then + `yarn install --frozen-lockfile`) +- `script/setup` — set up the repo for development after a fresh clone: runs + `script/bootstrap`, then `script/install-precommit` +- `script/projectname` — output the project name (our own extension); used by + `script/docker` for the image tag +- `script/test` — run the test suite (vitest, hard-capped at 30s where `timeout` + is available, verbose rerun on failure) +- `script/lint` — run eslint and a prettier check +- `script/fmt` — format all files with prettier (writes) +- `script/fmt-check` — check formatting (read-only) +- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own + extension) +- `script/docker` — build the Docker image, tagged via `script/projectname` + (byte-identical across repos) +- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs; the + image build runs `make check`) +- `script/precommit` — run by the git pre-commit hook (our own extension); runs + `script/lint` and `script/fmt-check` but deliberately not the tests, so the + TDD red-phase commit can land +- `script/install-precommit` — installs the git pre-commit hook (our own + extension); `make hooks` shims to it + +`make hooks` installs the pre-commit hook that runs `script/precommit`. + ## Rationale Ente is one of very few photo services with a credible end-to-end encryption @@ -113,11 +147,11 @@ All work on quak is test-driven. No exceptions. test-then-implementation sequence into reviewable commits, but the final history must still show tests landing before (or with) the matching implementation. -8. The pre-commit hook installed by `make hooks` runs - `make lint && make fmt-check`, not the full `make check`. This is deliberate - so the TDD red-phase commit (failing tests, no implementation yet) can land. - The full `make check` runs as part of `docker build .`, which is what CI - executes, so a red branch still cannot reach `main`. +8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which + runs the lint and format checks but not the full `make check`. This is + deliberate so the TDD red-phase commit (failing tests, no implementation yet) + can land. The full `make check` runs as part of `docker build .`, which is + what CI executes, so a red branch still cannot reach `main`. ## Design diff --git a/TODO.md b/TODO.md index 0458586..9efc22a 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ downloads, cover it with mock-server tests, and update the README TODO checkbox. # Completed Steps +- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile + shims, README Entrypoints section - 2026-06-10: Decrypted collections shared by other users (sealed-box keys); listCollections drops deleted-collection tombstones. - 2026-06-10: Login hardening: dual-2FA empty-string fields handled, TOTP diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..3c5ce34 --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,136 @@ +#!/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). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Pinned versions, 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="" + +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 +pkg_install() { + detect_pkgmgr + case "$PKGMGR" in + nix) nix-env -iA "nixpkgs.$1" ;; + apt) $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 +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 +} + +# 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" + + 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 + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..3e1778c --- /dev/null +++ b/script/check @@ -0,0 +1,14 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our own +# extension to scripts-to-rule-them-all. Must not modify any files. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/test" + "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" +} + +main "$@" diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..75cc3e6 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,13 @@ +#!/bin/sh +# script/cibuild: run the CI build. The Dockerfile runs script/check, so +# a successful build implies all checks pass. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..9b9ea86 --- /dev/null +++ b/script/docker @@ -0,0 +1,14 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# Identical in all repos; the tag comes from script/projectname. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..52825df --- /dev/null +++ b/script/fmt @@ -0,0 +1,12 @@ +#!/bin/sh +# script/fmt: format all files (writes). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + yarn run prettier --write . +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..adfaff4 --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,12 @@ +#!/bin/sh +# script/fmt-check: check formatting (read-only). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + yarn run prettier --check . +} + +main "$@" diff --git a/script/install-precommit b/script/install-precommit new file mode 100755 index 0000000..bef6406 --- /dev/null +++ b/script/install-precommit @@ -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' > .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + echo "pre-commit hook installed: runs script/precommit" +} + +main "$@" diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..3ede02b --- /dev/null +++ b/script/lint @@ -0,0 +1,13 @@ +#!/bin/sh +# script/lint: run the linter (eslint plus a prettier check). +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + yarn run eslint . + yarn run prettier --check . +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..07b9e7e --- /dev/null +++ b/script/precommit @@ -0,0 +1,18 @@ +#!/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. +# +# Runs lint and fmt-check but deliberately NOT the tests, so the TDD +# red-phase commit (failing tests, no implementation yet) can land. CI +# runs make check via docker build, which catches any branch that +# ships red. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..9e49dce --- /dev/null +++ b/script/projectname @@ -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 "quack" +} + +main "$@" diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..4cc5b6b --- /dev/null +++ b/script/setup @@ -0,0 +1,13 @@ +#!/bin/sh +# script/setup: set up the repo for development after a fresh clone: +# installs dependencies and the git pre-commit hook. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/install-precommit" +} + +main "$@" diff --git a/script/test b/script/test new file mode 100755 index 0000000..d4cec4b --- /dev/null +++ b/script/test @@ -0,0 +1,25 @@ +#!/bin/sh +# script/test: run the test suite. Uses `timeout` (GNU coreutils) when +# available so the run is hard-capped at 30s; on macOS without +# coreutils the cap is skipped. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +rerun_verbose() { + echo "--- Rerunning with verbose for details ---" + yarn run vitest run --reporter=verbose + exit 1 +} + +main() { + cd "$ROOT" + TIMEOUT="$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)" + if [ -n "$TIMEOUT" ]; then + "$TIMEOUT" 30s yarn run vitest run --reporter=dot || rerun_verbose + else + yarn run vitest run --reporter=dot || rerun_verbose + fi +} + +main "$@" diff --git a/yarn.lock b/yarn.lock index 2fb2dc0..ab845b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -314,7 +314,7 @@ "@inquirer/core" "^11.2.1" "@inquirer/type" "^4.0.7" -"@inquirer/prompts@^8.5.2": +"@inquirer/prompts@8.5.2": version "8.5.2" resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-8.5.2.tgz#09c0132ada2bbba94c91d341115e1e41cb3f1525" integrity sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==