diff --git a/.githooks/_common.sh b/.githooks/_common.sh new file mode 100644 index 0000000..037ba0d --- /dev/null +++ b/.githooks/_common.sh @@ -0,0 +1,17 @@ +# .githooks/_common.sh +# Shared colour variables and print helpers. +# Source this file after setting HOOK_NAME, e.g.: +# HOOK_NAME=pre-commit +# . "$(dirname "$0")/_common.sh" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +_TAG="${HOOK_NAME:-hook}" +print_info() { printf "${BLUE}[%s]${NC} %s\n" "$_TAG" "$1"; } +print_success() { printf "${GREEN}[%s]${NC} %s\n" "$_TAG" "$1"; } +print_warning() { printf "${YELLOW}[%s]${NC} %s\n" "$_TAG" "$1"; } +print_error() { printf "${RED}[%s]${NC} %s\n" "$_TAG" "$1"; } diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..6941482 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,34 @@ +#!/bin/sh +# .githooks/commit-msg +# Validates the commit message against the Conventional Commits spec. +# Tip: use `npx czg` to compose a properly-formatted message interactively. +# +# Install: run .githooks/setup.sh (or: git config core.hooksPath .githooks) +# Skip: git commit --no-verify + +HOOK_NAME=commit-msg +# shellcheck source=.githooks/_common.sh +. "$(dirname "$0")/_common.sh" + +COMMIT_MSG_FILE="$1" +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + +# Skip merge commits, reverts, fixups, and squash commits +case "$COMMIT_MSG" in + Merge\ *|Revert\ *|fixup!\ *|squash!\ *) exit 0 ;; +esac + +# Conventional Commits pattern: type(scope?)[!]: description +PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .{1,}' + +if ! echo "$COMMIT_MSG" | grep -Eq "$PATTERN"; then + print_error "Commit message does not follow Conventional Commits format." + print_error " Got: $COMMIT_MSG" + print_info " Expected: type(scope): description" + print_info " Types: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert" + print_info " Tip: use 'npx czg' to generate a valid message interactively." + exit 1 +fi + +print_success "Commit message format OK." +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..0d66289 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,87 @@ +#!/bin/sh +# .githooks/pre-commit +# Pre-commit hook: runs Biome checks per-directory for staged files, +# and zod schema tests when schema files are staged. +# +# Install: run .githooks/setup.sh (or: git config core.hooksPath .githooks) +# Skip: git commit --no-verify + +HOOK_NAME=pre-commit +# shellcheck source=.githooks/_common.sh +. "$(dirname "$0")/_common.sh" + +# ── Collect all staged files ───────────────────────────────────────────────── +STAGED=$(git diff --cached --name-only --diff-filter=ACMR) + +# ── 1. Biome check per directory ───────────────────────────────────────────── +print_info "Running Biome checks on staged directories…" + +BIOME_FAILED=0 + +run_biome_check() { + DIR="$1" + print_info " npx biome check $DIR" + npx biome check "$DIR" + if [ $? -ne 0 ]; then + print_error "Biome check failed for $DIR. Run 'npm run check' to auto-fix, then re-stage." + BIOME_FAILED=1 + else + print_success " $DIR ✓" + fi +} + +echo "$STAGED" | grep -q '^common/iso/' && run_biome_check common/iso +echo "$STAGED" | grep -q '^common/node/' && run_biome_check common/node +echo "$STAGED" | grep -q '^common/vue/' && run_biome_check common/vue +echo "$STAGED" | grep -q '^common/web/' && run_biome_check common/web +echo "$STAGED" | grep -q '^apps/' && run_biome_check apps +echo "$STAGED" | grep -q '^webs/' && run_biome_check webs +echo "$STAGED" | grep -q '^scripts/' && run_biome_check scripts + +if [ $BIOME_FAILED -ne 0 ]; then + exit 1 +fi + +# ── 2. Schema (Zod) validation tests ───────────────────────────────────────── +# Detect staged files under the known schema folders: +# common/schemas, apps/*/schemas +SCHEMA_FAILED=0 + +run_schema_tests() { + SCHEMA_DIR="$1" + if node -e "const p=require('./package.json'); process.exit(p.scripts&&p.scripts['test:schemas']?0:1)" 2>/dev/null; then + print_info "Running schema validation tests for $SCHEMA_DIR…" + npm run test:schemas -- "$SCHEMA_DIR" + if [ $? -ne 0 ]; then + print_error "Schema validation tests failed for $SCHEMA_DIR." + return 1 + fi + print_success "Schema validation tests passed for $SCHEMA_DIR." + else + print_warning "No 'test:schemas' script found – skipping schema tests for $SCHEMA_DIR." + fi + return 0 +} + +# Check common/schemas +if echo "$STAGED" | grep -q '^common/schemas/'; then + run_schema_tests common/schemas || SCHEMA_FAILED=1 +fi + +# Check apps/*/schemas (one call per matching app directory) +APP_SCHEMA_DIRS=$(echo "$STAGED" | grep -oE '^apps/[^/]+/schemas/' | sed 's|/$||' | sort -u) +for SCHEMA_DIR in $APP_SCHEMA_DIRS; do + run_schema_tests "$SCHEMA_DIR" || SCHEMA_FAILED=1 +done + +if [ $SCHEMA_FAILED -ne 0 ]; then + exit 1 +fi + +# Report skip only when no schema folders were matched at all +echo "$STAGED" | grep -qE '^common/schemas/|^apps/[^/]+/schemas/' \ + || print_info "No schema files staged – skipping schema tests." + +# ── Done ────────────────────────────────────────────────────────────────────── +print_success "All pre-commit checks passed." +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..813947d --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,116 @@ +#!/bin/sh +# .githooks/pre-push +# Pre-push hook: runs unit tests and schema validation tests before allowing a push. +# +# Install: run .githooks/setup.sh (or: git config core.hooksPath .githooks) +# Skip: git push --no-verify + +HOOK_NAME=pre-push +# shellcheck source=.githooks/_common.sh +. "$(dirname "$0")/_common.sh" + +# ── 1. Unit tests for impacted workspaces ───────────────────────────────────── +print_info "Detecting impacted workspaces…" + +EMPTY_TREE="4b825dc642cb6eb9a060e54bf8d69288fbee4904" +CHANGED_FILES="" + +while read -r local_ref local_sha remote_ref remote_sha; do + # Deleting a branch – nothing to test + [ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue + if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then + FILES=$(git diff --name-only "$EMPTY_TREE" "$local_sha" 2>/dev/null) + else + FILES=$(git diff --name-only "$remote_sha" "$local_sha" 2>/dev/null) + fi + CHANGED_FILES=$(printf '%s\n%s' "$CHANGED_FILES" "$FILES") +done + +CHANGED_FILES=$(printf '%s\n' "$CHANGED_FILES" | sort -u | grep -v '^$') + +IMPACTED_WS="" +for WS_DIR in apps/* common/*; do + [ -d "$WS_DIR" ] || continue + [ -f "$WS_DIR/package.json" ] || continue + if printf '%s\n' "$CHANGED_FILES" | grep -q "^${WS_DIR}/"; then + IMPACTED_WS="$IMPACTED_WS $WS_DIR" + fi +done + +if [ -z "$IMPACTED_WS" ]; then + print_warning "No workspace files changed – skipping unit tests." +else + TEST_EXIT=0 + for WS in $IMPACTED_WS; do + if node -e "const p=require('./${WS}/package.json'); process.exit(p.scripts&&p.scripts['test']?0:1)" 2>/dev/null; then + print_info "Running tests for workspace: $WS" + npm run test --workspace="$WS" + if [ $? -ne 0 ]; then + print_error "Tests failed for workspace: $WS" + TEST_EXIT=1 + else + print_success "Tests passed for workspace: $WS" + fi + else + print_warning "No 'test' script in $WS – skipping." + fi + done + + if [ $TEST_EXIT -ne 0 ]; then + print_error "Fix failing tests before pushing. Use 'git push --no-verify' to bypass." + exit 1 + fi +fi + +print_success "Unit tests passed." + +# ── 2. Schema validation tests ──────────────────────────────────────────────── +# Run schema tests for all known schema directories that exist in the repo. +if node -e "const p=require('./package.json'); process.exit(p.scripts&&p.scripts['test:schemas']?0:1)" 2>/dev/null; then + print_info "Running schema validation tests…" + SCHEMA_DIRS="" + [ -d "common/schemas" ] && SCHEMA_DIRS="$SCHEMA_DIRS common/schemas" + for DIR in apps/*/schemas; do + [ -d "$DIR" ] && SCHEMA_DIRS="$SCHEMA_DIRS $DIR" + done + + if [ -z "$SCHEMA_DIRS" ]; then + print_warning "No schema directories found – skipping schema tests." + else + SCHEMA_EXIT=0 + for SCHEMA_DIR in $SCHEMA_DIRS; do + npm run test:schemas -- "$SCHEMA_DIR" + if [ $? -ne 0 ]; then + print_error "Schema validation tests failed for $SCHEMA_DIR. Fix schema errors before pushing." + SCHEMA_EXIT=1 + else + print_success "Schema validation tests passed for $SCHEMA_DIR." + fi + done + if [ $SCHEMA_EXIT -ne 0 ]; then + exit 1 + fi + fi +else + print_warning "No 'test:schemas' script found – skipping schema tests." +fi + +# ── 3. Security audit ──────────────────────────────────────────────────────── +print_info "Running security audit…" +npm audit --audit-level=moderate +AUDIT_EXIT=$? +if [ $AUDIT_EXIT -ne 0 ]; then + print_warning "Security vulnerabilities found." + printf "Continue push anyway? (y/n) " + read -r REPLY + case "$REPLY" in + [Yy]*) print_info "Continuing push despite vulnerabilities." ;; + *) print_error "Push aborted. Fix vulnerabilities or use 'git push --no-verify' to bypass."; exit 1 ;; + esac +else + print_success "No security vulnerabilities found." +fi + +# ── Done ────────────────────────────────────────────────────────────────────── +print_success "All pre-push checks passed. Proceeding with push." +exit 0 diff --git a/.githooks/setup.sh b/.githooks/setup.sh new file mode 100755 index 0000000..2adb3f7 --- /dev/null +++ b/.githooks/setup.sh @@ -0,0 +1,60 @@ +#!/bin/sh +# .githooks/setup.sh +# Installs the project's native Git hooks by pointing git's hooksPath at +# the .githooks/ directory. Run this once after cloning the repository. +# +# Usage: +# chmod +x .githooks/setup.sh +# ./.githooks/setup.sh + +HOOK_NAME=setup +# shellcheck source=.githooks/_common.sh +. "$(dirname "$0")/_common.sh" + +# ── Verify we are inside a git repository ───────────────────────────────────── +if ! git rev-parse --git-dir >/dev/null 2>&1; then + print_error "Not a git repository. Please run this script from the project root." + exit 1 +fi + +REPO_ROOT=$(git rev-parse --show-toplevel) +HOOKS_DIR="$REPO_ROOT/.githooks" + +if [ ! -d "$HOOKS_DIR" ]; then + print_error ".githooks/ directory not found at $HOOKS_DIR" + exit 1 +fi + +# ── Make hook scripts executable ───────────────────────────────────────────── +print_info "Making hook scripts executable…" +chmod +x "$HOOKS_DIR/pre-commit" 2>/dev/null && print_success " pre-commit ✓" +chmod +x "$HOOKS_DIR/commit-msg" 2>/dev/null && print_success " commit-msg ✓" +chmod +x "$HOOKS_DIR/pre-push" 2>/dev/null && print_success " pre-push ✓" + +# ── Configure git to use .githooks/ ────────────────────────────────────────── +print_info "Configuring git hooksPath → .githooks" +git config core.hooksPath .githooks + +if [ $? -eq 0 ]; then + print_success "Git hooks installed successfully." + print_info " pre-commit: Biome check per directory, schema tests" + print_info " commit-msg: Conventional Commits format check (use 'npx czg' to compose)" + print_info " pre-push: unit tests + schema validation" + print_info "" + print_info "To skip a hook temporarily:" + print_info " git commit --no-verify" + print_info " git push --no-verify" +else + print_error "Failed to configure git hooksPath." + exit 1 +fi + +# ── Optional: verify czg is available ──────────────────────────────────────── +if command -v czg >/dev/null 2>&1; then + print_success "czg found: standardized commit messages are enabled." + print_info " Use 'czg' (or 'npx czg') instead of 'git commit -m …' for guided messages." + print_info " Use 'czg --ai' to generate an AI-assisted commit message." +else + print_warning "czg not found globally. Install it with: npm install -g czg" + print_info " You can still use 'npx czg' or 'npx czg --ai' without a global install." +fi diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 042139c..3ce23f9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,6 +4,70 @@ Hello and thank you for your interest in helping make express-template better. P ## IMPORTANT INFORMATION * For general questions, please join [Our Discussion Board](https://github.com/es-labs/express-template/discussions). +## Git Hooks Setup + +This project uses native Git hooks stored in `.githooks/`. After cloning, run the setup script once to activate them: + +```bash +# Make the setup script executable and run it +chmod +x .githooks/setup.sh +./.githooks/setup.sh +``` + +Or, if you prefer, configure the hooks path manually: + +```bash +git config core.hooksPath .githooks +chmod +x .githooks/pre-commit .githooks/pre-push +``` + +Running `npm install` will also run `npm prepare`, which configures the hooks path automatically. + +### pre-commit hook + +Runs automatically on every `git commit`: + +| Check | Details | +|-------|---------| +| **Biome format & lint** | Runs `npx biome check` on each affected directory (`common/iso`, `common/node`, `common/vue`, `common/web`, `apps`, `webs`, `scripts`). Run `npm run check` to auto-fix. | +| **Schema validation tests** | Runs `npm run test:schemas -- ` for each affected schema directory (`common/schema`, `common/schemas`, `apps/*/schema`, `apps/*/schemas`). | + +To skip the pre-commit hook temporarily: +```bash +git commit --no-verify +``` + +### Commit messages with czg + +For standardized [Conventional Commits](https://www.conventionalcommits.org/) messages, use **czg** instead of `git commit -m "…"`: + +```bash +# Interactive prompt (guided commit message) +npx czg + +# AI-generated commit message (requires API key configured in czg) +npx czg --ai +``` + +Install globally for convenience: +```bash +npm install -g czg +``` + +### pre-push hook + +Runs automatically on every `git push`: + +| Check | Details | +|-------|---------| +| **Unit tests** | Runs `npm run test:workspace` (or `npm test`). | +| **Schema validation tests** | Runs `npm run test:schemas` if the script exists. | + +To skip the pre-push hook temporarily: +```bash +git push --no-verify +``` + ## Reporting Issues * The issue list of this repo is **exclusively** for Bug Reports and Feature Requests. * Bug reproductions should be as **concise** as possible. @@ -19,3 +83,4 @@ Hello and thank you for your interest in helping make express-template better. P * Use a descriptive title no more than 64 characters long. This will be used as the commit message when your PR is merged. * For changes and feature requests, please include an example of what you are trying to solve and an example of the markup. It is preferred that you create an issue first however, as that will allow the team to review your proposal before you start. * Please reference the issue # that the PR resolves, something like `Fixes #1234` or `Resolves #6458` (See [closing issues using keywords](https://help.github.com/articles/closing-issues-using-keywords/)) + diff --git a/.github/workflows/hooks-ci.yml b/.github/workflows/hooks-ci.yml new file mode 100644 index 0000000..be84dea --- /dev/null +++ b/.github/workflows/hooks-ci.yml @@ -0,0 +1,120 @@ +name: Hooks CI + +on: + push: + branches: [main] + paths: + - 'common/**' + - 'apps/**' + - 'webs/**' + - 'scripts/**' + pull_request: + branches: [main] + +jobs: + # ── Commit message format check (mirrors commit-msg hook) ──────────────── + commit-lint: + name: Commit Message Format + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check commit messages + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?: .{1,}' + FAILED=0 + git log --format="%s" "$BASE_SHA".."$HEAD_SHA" > /tmp/commit_msgs.txt + while IFS= read -r msg; do + case "$msg" in + Merge\ *|Revert\ *|fixup!\ *|squash!\ *) continue ;; + esac + if ! echo "$msg" | grep -Eq "$PATTERN"; then + echo "::error::Non-conventional commit message: $msg" + echo " Expected: type(scope): description" + echo " Types: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert" + echo " Tip: use 'npx czg' locally to generate a valid message." + FAILED=1 + fi + done < /tmp/commit_msgs.txt + exit $FAILED + + # ── Biome checks (mirrors pre-commit hook) ─────────────────────────────── + biome: + name: Biome Checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Biome CI – common/iso + if: hashFiles('common/iso/**') != '' + run: npx biome ci common/iso + + - name: Biome CI – common/node + if: hashFiles('common/node/**') != '' + run: npx biome ci common/node + + - name: Biome CI – common/vue + if: hashFiles('common/vue/**') != '' + run: npx biome ci common/vue + + - name: Biome CI – common/web + if: hashFiles('common/web/**') != '' + run: npx biome ci common/web + + - name: Biome CI – apps + if: hashFiles('apps/**') != '' + run: npx biome ci apps + + - name: Biome CI – webs + if: hashFiles('webs/**') != '' + run: npx biome ci webs + + - name: Biome CI – scripts + if: hashFiles('scripts/**') != '' + run: npx biome ci scripts + + # ── Schema validation tests (mirrors pre-push hook) ────────────────────── + schema-tests: + name: Schema Validation Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Schema tests – common/schemas + if: hashFiles('common/schemas/**') != '' + run: npm run test:schemas -- common/schemas + + - name: Schema tests – apps/*/schemas + run: | + for dir in apps/*/schemas; do + [ -d "$dir" ] || continue + echo "Running schema tests for $dir" + npm run test:schemas -- "$dir" + done diff --git a/czg.config.js b/czg.config.js new file mode 100644 index 0000000..0c60517 --- /dev/null +++ b/czg.config.js @@ -0,0 +1,24 @@ +import { readdirSync, statSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(fileURLToPath(import.meta.url)) + +function listDirs(parent) { + try { + return readdirSync(join(root, parent)) + .filter((name) => statSync(join(root, parent, name)).isDirectory()) + .map((name) => `${parent}/${name}`) + } catch { + return [] + } +} + +const workspaceScopes = [...listDirs('apps'), ...listDirs('common')] + +/** @type {import('czg').UserConfig} */ +export default { + prompt: { + scopes: ['NA', ...workspaceScopes], + }, +} diff --git a/package.json b/package.json index 0d70c20..489b4aa 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "docs:validate-swagger": "swagger-cli validate docs/openapi/openapi.merged.yaml", "docs:validate": "npx redocly lint docs/openapi/openapi.merged.yaml", "docs:make-html": "npx @redocly/cli build-docs docs/openapi/openapi.merged.yaml --output index.html", - "prepare": "git config core.hooksPath ./git-hooks || echo 'Can not set git hooks'", + "prepare": "git config core.hooksPath .githooks || echo 'Can not set git hooks'", "test:workspace": "npm run test --workspace=apps/app-sample", "test:workspaces": "npm run test --workspaces", "test:cover-only": "node --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info --test __tests__/**/*.spec.js",