generated from mathias/template-go-web
Initial commit
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generates harness-specific context files from .context/PROJECT.md
|
||||
# Project-level script — run from a project directory.
|
||||
#
|
||||
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
||||
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
||||
#
|
||||
# Cursor and Aider adapters removed 2026-09-01 — neither is a supported harness
|
||||
# any more. Supported: Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush,
|
||||
# Pi. All read CLAUDE.md or AGENTS.md; system-prompt.txt remains for Open WebUI,
|
||||
# Mods and raw API use.
|
||||
#
|
||||
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
|
||||
# Task: task context:sync
|
||||
#
|
||||
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Parse --force flag and collect adapter names separately
|
||||
FORCE=false
|
||||
ADAPTERS=()
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--force) FORCE=true ;;
|
||||
*) ADAPTERS+=("$_arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROJECT_FILE=".context/PROJECT.md"
|
||||
|
||||
# Walk up to find root .context/AGENT.md
|
||||
find_root_context() {
|
||||
local dir
|
||||
dir="$(pwd)"
|
||||
while [ "$dir" != "/" ]; do
|
||||
dir="$(dirname "$dir")"
|
||||
if [ -f "$dir/.context/AGENT.md" ]; then
|
||||
echo "$dir/.context/AGENT.md"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
ROOT_CONTEXT="${ROOT_CONTEXT:-$(find_root_context)}"
|
||||
|
||||
if [ ! -f "$PROJECT_FILE" ]; then
|
||||
echo "Error: $PROJECT_FILE not found. Are you in a project root?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pre-flight: reject unfilled {{...}} placeholders unless --force
|
||||
if [ "$FORCE" = false ]; then
|
||||
_placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true)
|
||||
if [ -n "$_placeholders" ]; then
|
||||
echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2
|
||||
while IFS= read -r _match; do
|
||||
_lineno="${_match%%:*}"
|
||||
_content="${_match#*:}"
|
||||
_token=$(head -1 < <(grep -o '{{[^}]*}}' <<<"$_content"))
|
||||
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||
done <<< "$_placeholders"
|
||||
echo "" >&2
|
||||
echo "Fill these placeholders, then re-run: task context:sync" >&2
|
||||
echo "To bypass validation: bash scripts/context-sync.sh --force" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||
echo " Root context: $ROOT_CONTEXT"
|
||||
else
|
||||
echo " No root AGENT.md found (project context only)"
|
||||
fi
|
||||
|
||||
# Emit root context + separator
|
||||
root_block() {
|
||||
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||
cat "$ROOT_CONTEXT"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Claude Code ──────────────────────────────────────────────
|
||||
# Claude Code walks up the tree — it finds ~/dev/CLAUDE.md automatically.
|
||||
# Project-level CLAUDE.md only needs project-specific context.
|
||||
generate_claude() {
|
||||
cat "$PROJECT_FILE" > CLAUDE.md
|
||||
echo " → CLAUDE.md (project-only; Claude Code inherits root)"
|
||||
}
|
||||
|
||||
# ── AGENTS.md (Crush, Pi, Antigravity) ──────────────────────
|
||||
# These tools read AGENTS.md from cwd but don't walk up.
|
||||
# Concatenate root + project.
|
||||
generate_agents() {
|
||||
{ root_block; cat "$PROJECT_FILE"; } > AGENTS.md
|
||||
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
|
||||
}
|
||||
|
||||
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
|
||||
generate_system_prompt() {
|
||||
{
|
||||
echo "You are a coding assistant working on a specific project."
|
||||
echo "Follow all conventions from both the root agent context and project context."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
root_block
|
||||
cat "$PROJECT_FILE"
|
||||
echo ""
|
||||
echo "---"
|
||||
} > .context/system-prompt.txt
|
||||
echo " → .context/system-prompt.txt (root + project)"
|
||||
}
|
||||
|
||||
# ── MCP config ───────────────────────────────────────────────
|
||||
generate_mcp() {
|
||||
# Ensure baseline file exists with project-specific knowledge server
|
||||
if [ ! -f .context/mcp.json ]; then
|
||||
cat > .context/mcp.json << 'JSON'
|
||||
{
|
||||
"mcpServers": {
|
||||
"knowledge": {
|
||||
"url": "http://localhost:3100/mcp",
|
||||
"description": "Project knowledge base — vector + graph retrieval"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
fi
|
||||
|
||||
# Merge root mcp-servers.json if found alongside root AGENT.md
|
||||
local root_mcp=""
|
||||
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||
local candidate
|
||||
candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json"
|
||||
[ -f "$candidate" ] && root_mcp="$candidate"
|
||||
fi
|
||||
|
||||
if [ -z "$root_mcp" ]; then
|
||||
echo " → .context/mcp.json (exists, no root mcp-servers.json found)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Root servers take precedence over project entries on key conflict
|
||||
local root_servers count updated
|
||||
root_servers=$(jq '.servers' "$root_mcp")
|
||||
count=$(printf '%s' "$root_servers" | jq 'keys | length')
|
||||
updated=$(jq --argjson root "$root_servers" \
|
||||
'.mcpServers = (.mcpServers + $root)' \
|
||||
.context/mcp.json)
|
||||
printf '%s\n' "$updated" > .context/mcp.json
|
||||
echo " → .context/mcp.json (merged $count root servers)"
|
||||
}
|
||||
|
||||
echo "Syncing project context from $PROJECT_FILE..."
|
||||
|
||||
if [ ${#ADAPTERS[@]} -eq 0 ]; then
|
||||
generate_claude
|
||||
generate_agents
|
||||
generate_system_prompt
|
||||
generate_mcp
|
||||
else
|
||||
for adapter in "${ADAPTERS[@]}"; do
|
||||
case "$adapter" in
|
||||
claude) generate_claude ;;
|
||||
agents) generate_agents ;;
|
||||
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||
mcp) generate_mcp ;;
|
||||
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Is this checkout still a raw, unsubstituted template?
|
||||
#
|
||||
# Prints "true" or "false". cd.yml's guard job reads it and skips check/build/
|
||||
# deploy when true, so that a template repo does not try to build itself.
|
||||
#
|
||||
# WHY A PATTERN AND NOT A LITERAL
|
||||
#
|
||||
# The scaffolders (gitea-mcp create_project_from_template, local-dev
|
||||
# scripts/new-project.sh) substitute placeholders in EVERY file of the tree —
|
||||
# including this one. So a guard that greps a whole placeholder token is
|
||||
# rewritten by the very substitution it exists to detect: in the template it
|
||||
# greps the module-path placeholder, and in the generated repo that same line
|
||||
# has become a grep for the real module path — which the generated go.mod
|
||||
# declares on its first line. It matches forever, every job below skips, and the
|
||||
# run reports green having built nothing.
|
||||
#
|
||||
# That shipped twice: #9 (cad-atlas) and #10 (openbanking-api), the second one
|
||||
# sitting directly under a comment warning against exactly this.
|
||||
#
|
||||
# Note this comment names no placeholder literally, for the same reason. A
|
||||
# comment that gets substituted stops explaining the thing it documents — and
|
||||
# scripts/tests/is-template_test.sh enforces that, which is how the first draft
|
||||
# of this file was caught.
|
||||
#
|
||||
# The regex below matches the SHAPE of a placeholder and contains no complete
|
||||
# token, so there is nothing for a scaffolder to rewrite. It also catches any
|
||||
# other leftover placeholder, not just the two the scaffolders know about.
|
||||
#
|
||||
# scripts/tests/is-template_test.sh substitutes a copy of this file and asserts
|
||||
# it still answers correctly — that case is the regression test for the class.
|
||||
set -euo pipefail
|
||||
|
||||
target="${1:-go.mod}"
|
||||
|
||||
if [ ! -f "$target" ]; then
|
||||
# Never answer "false" here. A missing target would skip CI on a repo that is
|
||||
# not a template at all, which is the same fake-green failure by another road.
|
||||
echo "is-template: no such file: $target" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if grep -qE '__[A-Z][A-Z0-9_]*__' "$target"; then
|
||||
echo "true"
|
||||
else
|
||||
echo "false"
|
||||
fi
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Does a repo generated from this template pass its own gate?
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# `golangci-lint run ./...` and friends cannot see this template's entrypoint.
|
||||
# Go skips directories whose names begin with "_" or "." when expanding package
|
||||
# patterns, and the entrypoint lives in cmd/__PROJECT_NAME__:
|
||||
#
|
||||
# $ go list ./...
|
||||
# __MODULE_PATH__/internal/web <- no cmd package
|
||||
#
|
||||
# After substitution the directory has a real name, becomes visible, and any
|
||||
# defect in it appears — in the child, not here. That is how template-go-agent
|
||||
# shipped a broken entrypoint for three months while its own CI stayed green
|
||||
# (template-go-agent#1, #2).
|
||||
#
|
||||
# Linting the directory by name is not a fix: `golangci-lint run
|
||||
# ./cmd/__PROJECT_NAME__` works, but reports formatter findings that do not
|
||||
# exist in a generated repo, because gofumpt's import grouping depends on the
|
||||
# module path and this one is a placeholder.
|
||||
#
|
||||
# So this substitutes the tracked tree into a throwaway child and gates the
|
||||
# RESULT, which is the only artifact anyone actually runs.
|
||||
#
|
||||
# Usage: ./scripts/tests/generated-child_test.sh
|
||||
|
||||
set -uo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
|
||||
bad() { printf ' FAIL %s\n %s\n' "$1" "${2:-}"; fail=$((fail+1)); }
|
||||
|
||||
echo "generated-child tests"
|
||||
|
||||
# A missing tool is a FAILURE, not a skip. An `exit 0` with zero assertions is
|
||||
# indistinguishable from a real pass to anything reading the exit code -- which
|
||||
# is the whole failure mode this harness exists to close.
|
||||
for tool in golangci-lint templ; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
bad "$tool is available" "not on PATH — the generated child cannot be gated"
|
||||
echo; echo "$pass passed, $fail failed"; exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
# Tracked files from the WORKING TREE, not `git archive HEAD`: this runs inside
|
||||
# `task check`, so it must gate what is about to be committed rather than the
|
||||
# previous commit. `git ls-files` keeps the file set honest while taking
|
||||
# current content.
|
||||
( cd "$ROOT" && git ls-files -z ) | while IFS= read -r -d '' f; do
|
||||
mkdir -p "$tmp/$(dirname "$f")"
|
||||
cp "$ROOT/$f" "$tmp/$f"
|
||||
done
|
||||
|
||||
if [ ! -d "$tmp/cmd/__PROJECT_NAME__" ]; then
|
||||
bad "the template still has a placeholder entrypoint directory" \
|
||||
"cmd/__PROJECT_NAME__ not found — has the layout changed?"
|
||||
echo; echo "$pass passed, $fail failed"; exit 1
|
||||
fi
|
||||
mv "$tmp/cmd/__PROJECT_NAME__" "$tmp/cmd/childproj"
|
||||
find "$tmp" -type f \( -name '*.go' -o -name '*.templ' -o -name '*.mod' -o -name '*.yml' -o -name '*.md' -o -name 'Dockerfile' \) \
|
||||
-exec sed -i \
|
||||
-e 's|__MODULE_PATH__|git.d-ma.be/mathias/childproj|g' \
|
||||
-e 's|__PROJECT_NAME__|childproj|g' {} +
|
||||
|
||||
# Generated files are gitignored, so the child arrives without them — exactly as
|
||||
# a freshly scaffolded repo does. Generating here also proves the pinned
|
||||
# generator and the pinned library agree, which is template-go-web#8.
|
||||
out="$(cd "$tmp" && templ generate 2>&1)"; rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
ok "templ generate succeeds in the generated child"
|
||||
else
|
||||
bad "templ generate succeeds in the generated child" "exit $rc: $(printf '%s' "$out" | tail -2)"
|
||||
fi
|
||||
|
||||
# The whole point: the entrypoint must be visible to Go tooling in the child.
|
||||
# If this regresses, everything below it passes for the wrong reason.
|
||||
if ( cd "$tmp" && go list ./... 2>/dev/null ) | grep -q '/cmd/childproj$'; then
|
||||
ok "the generated child's entrypoint is visible to go list"
|
||||
else
|
||||
bad "the generated child's entrypoint is visible to go list" \
|
||||
"cmd package missing from ./... — the child would be unchecked too"
|
||||
fi
|
||||
|
||||
out="$(cd "$tmp" && go build ./... 2>&1)"
|
||||
[ -z "$out" ] && ok "the generated child builds" \
|
||||
|| bad "the generated child builds" "$(printf '%s' "$out" | head -3)"
|
||||
|
||||
out="$(cd "$tmp" && go vet ./... 2>&1)"
|
||||
[ -z "$out" ] && ok "the generated child passes go vet" \
|
||||
|| bad "the generated child passes go vet" "$(printf '%s' "$out" | head -3)"
|
||||
|
||||
# Graded on the EXIT CODE, never on grepping the output. golangci-lint exits
|
||||
# 0 clean / 1 issues found / >1 the linter itself failed. Matching its
|
||||
# human-readable summary instead scores a CRASHED linter as a pass -- bad
|
||||
# config, unwritable TMPDIR, OOM, or simply a future change to its output
|
||||
# format all produce non-matching text. Since this harness is the only gate
|
||||
# covering the entrypoint, that would leave cmd/__PROJECT_NAME__ unchecked with
|
||||
# CI green, which is precisely the defect class this file exists to end.
|
||||
out="$(cd "$tmp" && golangci-lint run ./... 2>&1)"; rc=$?
|
||||
case "$rc" in
|
||||
0) ok "the generated child passes golangci-lint" ;;
|
||||
1) bad "the generated child passes golangci-lint" \
|
||||
"$(printf '%s' "$out" | grep -E '\.go:[0-9]+' | head -3)" ;;
|
||||
*) bad "golangci-lint ran successfully" \
|
||||
"linter exited $rc — the child is UNCHECKED, not clean: $(printf '%s' "$out" | tail -2)" ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
echo "$pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for scripts/is-template.sh
|
||||
#
|
||||
# This guard decides whether a checkout is still a raw template. Every job in
|
||||
# cd.yml keys off it, so when it answers wrongly the entire pipeline skips and
|
||||
# reports green — which is how `cad-atlas` and `openbanking-api` were born with
|
||||
# CI that had never built, tested or deployed them (#9, #10).
|
||||
#
|
||||
# The defect both times was the same: the guard grepped for a literal
|
||||
# placeholder, and the scaffolder substitutes placeholders in EVERY file,
|
||||
# including the guard itself. So in a generated repo the check became
|
||||
# `grep -q 'git.d-ma.be/mathias/<name>' go.mod` against a go.mod that reads
|
||||
# `module git.d-ma.be/mathias/<name>` — a match that can never fail.
|
||||
#
|
||||
# The third test below is the one that matters: it runs a scaffolder-style
|
||||
# substitution OVER THE GUARD and asserts it still answers correctly. Both
|
||||
# shipped bugs would have failed it.
|
||||
#
|
||||
# Usage: ./scripts/tests/is-template_test.sh
|
||||
|
||||
set -uo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT/scripts/is-template.sh"
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
|
||||
bad() { printf ' FAIL %s\n %s\n' "$1" "${2:-}"; fail=$((fail+1)); }
|
||||
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
echo "is-template tests"
|
||||
|
||||
# Placeholder tokens are ASSEMBLED, never written whole. The scaffolder rewrites
|
||||
# every file in the tree, this one included -- a fixture containing a literal
|
||||
# token would be substituted before the test ran, and the suite would fail in
|
||||
# every generated repo while passing here. Which is the same class of bug the
|
||||
# guard itself had.
|
||||
U="__"
|
||||
ph() { printf '%s%s%s' "$U" "$1" "$U"; }
|
||||
MODULE_PH="$(ph MODULE_PATH)"
|
||||
PROJECT_PH="$(ph PROJECT_NAME)"
|
||||
OTHER_PH="$(ph SOME_OTHER)"
|
||||
|
||||
if [ -f "$SCRIPT" ]; then ok "script exists"
|
||||
else bad "script exists" "$SCRIPT missing"; echo; echo "$pass passed, $fail failed"; exit 1; fi
|
||||
|
||||
# A raw template still carries placeholders.
|
||||
printf 'module git.d-ma.be/mathias/%s\n\ngo 1.26\n' "$MODULE_PH" > "$tmp/raw.go.mod"
|
||||
got="$(bash "$SCRIPT" "$tmp/raw.go.mod")"
|
||||
[ "$got" = "true" ] && ok "raw template is detected" \
|
||||
|| bad "raw template is detected" "got '$got', want 'true'"
|
||||
|
||||
# A generated repo does not. This is the assertion both shipped guards failed.
|
||||
printf 'module git.d-ma.be/mathias/openbanking-api\n\ngo 1.26\n' > "$tmp/real.go.mod"
|
||||
got="$(bash "$SCRIPT" "$tmp/real.go.mod")"
|
||||
[ "$got" = "false" ] && ok "a generated repo is NOT detected as a template" \
|
||||
|| bad "a generated repo is NOT detected as a template" "got '$got', want 'false'"
|
||||
|
||||
# THE ONE THAT MATTERS.
|
||||
#
|
||||
# Copy the guard and run a scaffolder-style substitution over it, exactly as
|
||||
# create_project_from_template and new-project.sh do to every file in the tree.
|
||||
# The substituted guard must still answer correctly. A guard that greps a whole
|
||||
# placeholder token gets rewritten here and starts matching forever.
|
||||
cp "$SCRIPT" "$tmp/substituted.sh"
|
||||
sed -i \
|
||||
-e "s|$MODULE_PH|git.d-ma.be/mathias/openbanking-api|g" \
|
||||
-e "s|$PROJECT_PH|openbanking-api|g" \
|
||||
"$tmp/substituted.sh"
|
||||
|
||||
got="$(bash "$tmp/substituted.sh" "$tmp/real.go.mod")"
|
||||
if [ "$got" = "false" ]; then
|
||||
ok "the guard survives being substituted itself"
|
||||
else
|
||||
bad "the guard survives being substituted itself" \
|
||||
"after substitution it answered '$got' for a generated repo — this is #9/#10"
|
||||
fi
|
||||
|
||||
got="$(bash "$tmp/substituted.sh" "$tmp/raw.go.mod")"
|
||||
[ "$got" = "true" ] && ok "the substituted guard still detects a raw template" \
|
||||
|| bad "the substituted guard still detects a raw template" "got '$got'"
|
||||
|
||||
# Belt and braces: the source must not contain a complete substitutable token.
|
||||
if grep -qE "$(printf '%s(MODULE_PATH|PROJECT_NAME)%s' "$U" "$U")" "$SCRIPT"; then
|
||||
bad "the guard contains no substitutable placeholder token" \
|
||||
"found one in $SCRIPT — the scaffolder will rewrite it"
|
||||
else
|
||||
ok "the guard contains no substitutable placeholder token"
|
||||
fi
|
||||
|
||||
# Any leftover placeholder counts, not just the two the scaffolder knows about —
|
||||
# a half-substituted tree is not a working repo either.
|
||||
printf 'module git.d-ma.be/mathias/thing\n\n// %s\ngo 1.26\n' "$OTHER_PH" > "$tmp/partial.go.mod"
|
||||
got="$(bash "$SCRIPT" "$tmp/partial.go.mod")"
|
||||
[ "$got" = "true" ] && ok "any leftover placeholder counts" \
|
||||
|| bad "any leftover placeholder counts" "got '$got', want 'true'"
|
||||
|
||||
# A missing file is not "this is a real repo" — failing open here would skip CI.
|
||||
bash "$SCRIPT" "$tmp/nope.go.mod" >/dev/null 2>&1
|
||||
rc=$?
|
||||
[ "$rc" -ne 0 ] && ok "a missing target fails loudly rather than answering false" \
|
||||
|| bad "a missing target fails loudly rather than answering false" "exit 0"
|
||||
|
||||
echo
|
||||
echo "$pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
Reference in New Issue
Block a user