Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbootstrap
More file actions
Latest commit
executable file
·362 lines (314 loc) · 12 KB
/
Copy pathbootstrap
File metadata and controls
executable file
·362 lines (314 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
#!/usr/bin/env bash
# Turn this template into a project. Deletes itself on success.
set -euo pipefail
USE_DEFAULTS=false
WANT_DDD=false
forargin"$@";do
case"$arg"in
--defaults) USE_DEFAULTS=true ;;
--ddd) WANT_DDD=true ;;
*)
echo"usage: $0 [--defaults] [--ddd]">&2
exit 2
;;
esac
done
command -v uv > /dev/null || {
echo"bootstrap: uv is required (https://docs.astral.sh/uv/)">&2
exit 1
}
command -v python3 > /dev/null || {
echo"bootstrap: python3 is required">&2
exit 1
}
repo_name=$(basename "$(git rev-parse --show-toplevel)")
ask() { # ask VAR PROMPT DEFAULT
local var=$1 prompt=$2 default=$3 reply
read -r -p "$prompt [$default]: " reply
printf -v "$var"'%s'"${reply:-$default}"
}
if$USE_DEFAULTS;then
# shellcheck source=.bootstrap-defaults disable=SC1091
. ./.bootstrap-defaults
else
echo"Configuring this template."
ask PROJECT_NAME "Project name""$repo_name"
ask PACKAGE_NAME "Package name""${PROJECT_NAME//-/_}"
ask CLI_NAME "CLI command""$PROJECT_NAME"
ask DESCRIPTION "Description""A tool that does a thing."
ask AUTHOR "Author""$(git config user.name ||echo'Robin Bowes')"
read -r -p " DDD layering? [y/N]: " reply
[[ "$reply"=~ ^[Yy]$ ]] && WANT_DDD=true
fi
# PACKAGE_NAME becomes a Python package directory and module path. Validate it
# before any destructive work happens -- a bad value must not silently rename
# src/pythontemplate to something unimportable and then commit it.
[[ "$PACKAGE_NAME"=~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || {
echo"bootstrap: invalid PACKAGE_NAME '$PACKAGE_NAME' -- must be a legal" \
"Python identifier matching ^[A-Za-z_][A-Za-z0-9_]*\$">&2
exit 1
}
# PROJECT_NAME becomes the PEP 508 distribution name, the GitHub Pages
# basePath and the Fumadocs appName; CLI_NAME becomes a console-script
# command. Both need the same up-front check: `uv sync` rejecting an illegal
# distribution name half-way through leaves a fully rewritten working tree.
forpairin"PROJECT_NAME:$PROJECT_NAME""CLI_NAME:$CLI_NAME";do
[[ "${pair#*:}"=~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || {
echo"bootstrap: invalid ${pair%%:*} '${pair#*:}' -- must match" \
"^[A-Za-z0-9][A-Za-z0-9._-]*\$">&2
exit 1
}
done
# uv with UV_FROZEN cleared, resolved past any mise shim. Used only where uv
# must write uv.lock. An explicit `UV_FROZEN=0` is not sufficient: if uv on PATH
# is a mise shim it re-derives [env] from mise.toml inside the child process and
# restores the "1", so the write is silently skipped. Same reasoning as
# Taskfile.yml's uv:lock task.
#
# The final `uv sync` does NOT need this: the substitution above rewrites the
# project name inside uv.lock as well, so the lockfile is already consistent
# with the renamed pyproject.toml and --frozen is satisfied.
uv_unfrozen() {
env -u UV_FROZEN "$(mise which uv 2> /dev/null ||command -v uv)""$@"
}
# Everything past this point rewrites the tree in place, so a failure leaves
# a half-converted checkout. Say how to get back rather than dying silently.
recover() {
echo"bootstrap: failed part-way through -- the tree is half-rewritten.">&2
echo"bootstrap: discard it with: git reset --hard && git clean -fd">&2
}
trap recover ERR
# Escape a value for safe use as a sed replacement (the s|X|Y|g right-hand
# side): backslash first, then & ("whole match") and | (our delimiter).
# Applied once here so every substitution below is protected, rather than
# repeating the escaping at each -e.
sed_escape() {
local s=$1
s=${s//\\/\\\\}
s=${s//&/\\&}
s=${s//|/\\|}
printf'%s'"$s"
}
project_name_esc=$(sed_escape "$PROJECT_NAME")
package_name_esc=$(sed_escape "$PACKAGE_NAME")
description_esc=$(sed_escape "$DESCRIPTION")
author_esc=$(sed_escape "$AUTHOR")
cli_name_esc=$(sed_escape "$CLI_NAME")
# Files carrying placeholders. Every template file that mentions a placeholder
# must appear here or scripts/test-bootstrap.sh will fail.
# Read into an array without `mapfile` -- that is a bash 4 builtin and
# macOS still ships bash 3.2 at /bin/bash.
files=()
while IFS= read -r line;do
files+=("$line")
done<<(
git ls-files \
| grep -v -e '^bootstrap$' -e '^\.bootstrap-defaults$' \
-e '^docs/superpowers/' -e '^LICENSE$'
)
# Order matters: rewrite the kebab form first. `pythontemplate` contains no
# hyphen so the two never overlap, but keeping the order fixed makes the
# substitution auditable.
forfin"${files[@]}";do
[[ -f"$f" ]] ||continue
sed -i.bak \
-e "s|python-template|$project_name_esc|g" \
-e "s|pythontemplate|$package_name_esc|g" \
-e "s|A template for yo61 Python projects\.|$description_esc|g" \
-e "s|Robin Bowes|$author_esc|g" \
"$f"
rm -f "$f.bak"
done
# The CLI command name is independent of the project name.
sed -i.bak "s|^$PROJECT_NAME = \"$PACKAGE_NAME.cli:main\"|$cli_name_esc = \"$PACKAGE_NAME.cli:main\"|" \
pyproject.toml && rm -f pyproject.toml.bak
git mv "src/pythontemplate""src/$PACKAGE_NAME"
# Reset release state. CHANGELOG.md is emptied, not seeded with a heading:
# release-please's Changelog updater treats any pre-existing content as prior
# changelog body when it finds no version heading, so it demotes `# Changelog`
# to `## Changelog` and appends it below the first release's entry
# (src/updaters/changelog.ts, adjustHeaders). An empty file takes the clean
# branch instead.
printf'{".":"0.0.0"}\n'> .release-please-manifest.json
:> CHANGELOG.md
# Template-only content must not propagate into the generated project: the
# design records, and the smoke test that drives ./bootstrap -- which is about
# to delete itself, so the test could only ever exit 127 from here on.
rm -f docs/superpowers/specs/2026-08-24-python-template-rewrite-design.md
rm -f docs/superpowers/plans/2026-08-24-python-template-rewrite.md
rm -f scripts/test-bootstrap.sh
rmdir scripts 2> /dev/null ||true
# LICENSE is excluded from the bulk sweep -- a blanket substitution across
# Apache-2.0 text risks mangling the licence body -- so rewrite the single
# copyright line on its own, then prove the rewrite landed.
license_year=$(date +%Y)
sed -i.bak \
"s|^\([[:space:]]*\)Copyright [0-9][0-9][0-9][0-9] .*$|\1Copyright $license_year$author_esc|" \
LICENSE
rm -f LICENSE.bak
grep -Fq "Copyright $license_year$AUTHOR" LICENSE || {
echo"bootstrap: failed to rewrite the LICENSE copyright line">&2
exit 1
}
# Three files still carry template-only instructions at this point: ci.yaml's
# bootstrap job (its script is gone), CLAUDE.md's Placeholders section (false
# once the values are the project's own), and README.md (the template's
# onboarding doc). Patch them by exact-match anchor so a stale anchor fails
# loudly rather than silently no-opping. A YAML round-trip is not an option:
# it would reformat ci.yaml and drop every comment.
PROJECT_NAME="$PROJECT_NAME" DESCRIPTION="$DESCRIPTION" CLI_NAME="$CLI_NAME" \
python3 << 'PY'
import os
import pathlib
project = os.environ["PROJECT_NAME"]
description = os.environ["DESCRIPTION"]
cli = os.environ["CLI_NAME"]
def cut(text, start, end, last_line, what):
"""Drop [start, end) from text, or die naming the anchor that moved.
last_line pins the far edge: without it, a job inserted between the two
anchors would be deleted along with the intended span, silently.
"""
i = text.find(start)
j = text.find(end, i + len(start)) if i != -1 else -1
if i == -1 or j == -1:
raise SystemExit(f"bootstrap: anchor moved, cannot remove {what}")
if not text[i:j].rstrip("\n").endswith(last_line):
raise SystemExit(f"bootstrap: {what} no longer ends at {last_line!r}")
return text[:i] + text[j:]
def sub(text, old, new, what):
if old not in text:
raise SystemExit(f"bootstrap: anchor moved, cannot patch {what}")
return text.replace(old, new, 1)
ci_path = pathlib.Path(".github/workflows/ci.yaml")
ci = ci_path.read_text()
ci = cut(
ci,
" bootstrap:\n name: bootstrap\n",
" # Aggregator.",
" - run: ./scripts/test-bootstrap.sh",
"the bootstrap job",
)
ci = sub(
ci,
" needs: [lint, pytest, bootstrap]\n",
" needs: [lint, pytest]\n",
"the aggregator needs list",
)
ci = sub(
ci,
" BOOTSTRAP: ${{ needs.bootstrap.result }}\n",
"",
"the aggregator BOOTSTRAP env var",
)
ci = sub(
ci,
' if [[ "$LINT" != "success" || "$PYTEST" != "success"'
' || "$BOOTSTRAP" != "success" ]]; then\n'
' echo "lint=$LINT pytest=$PYTEST bootstrap=$BOOTSTRAP"\n',
' if [[ "$LINT" != "success" || "$PYTEST" != "success" ]]; then\n'
' echo "lint=$LINT pytest=$PYTEST"\n',
"the aggregator condition",
)
ci_path.write_text(ci)
claude_path = pathlib.Path("CLAUDE.md")
doc = claude_path.read_text()
start = doc.find("## Placeholders\n")
if start == -1:
raise SystemExit("bootstrap: CLAUDE.md has no Placeholders section")
end = doc.find("\n## ", start + 1)
head = doc[:start].rstrip("\n")
tail = doc[end + 1 :] if end != -1 else ""
claude_path.write_text(f"{head}\n\n{tail}" if tail else f"{head}\n")
pathlib.Path("README.md").write_text(f"""# {project}
{description}
## Install
```bash
uv sync
```
## Run
```bash
uv run {cli} --help
```
## Develop
`task dev:check` is the gate: `ruff check`, `ruff format --check`, `ty check`
and `pytest`. Run it before every commit — CI runs the same checks through the
prek hooks in `.pre-commit-config.yaml`.
`task --list` shows every task. `task dev:hooks-install` installs the local
git hooks.
Agent-facing scaffolding lives in `CLAUDE.md` (project instructions),
`decisions/` (decision records), `quality/criteria.md` (the quality gate) and
`docs/superpowers/` (specs and plans).
## Docs
The docs site is in `docs/site/`, built with Fumadocs and published to
<https://yo61.github.io/{project}/> on every push to `main`.
- `task docs:dev` — hot-reloading site on <http://localhost:3000>
- `task docs:serve` — production build served locally, without the Pages
`basePath`
- `task docs:build` — production build with the `/{project}` `basePath`,
matching CI
""")
PY
if$WANT_DDD;then
forlayerin application domain infrastructure;do
mkdir -p "src/$PACKAGE_NAME/$layer"
printf'"""%s layer."""\n'"$layer">"src/$PACKAGE_NAME/$layer/__init__.py"
done
# Uncomment the importlinter block: strip the leading '# ' from every line
# between the marker and end of file.
sed -i.bak '/^# \[tool.importlinter\]/,$ s/^# \{0,1\}//' pyproject.toml
rm -f pyproject.toml.bak
# `uv add` does not refuse under UV_FROZEN=1 -- it rewrites pyproject.toml,
# leaves uv.lock stale and still exits 0 -- so clearing the variable is what
# keeps the two in step, not a way past a hard error.
uv_unfrozen add --dev --quiet 'import-linter>=2'
python3 << 'PY'
import pathlib
tf = pathlib.Path("Taskfile.yml")
s = tf.read_text()
s = s.replace(
" dev:test:\n",
" dev:imports:\n"
" desc: Enforce import boundaries (import-linter)\n"
" cmds:\n"
" - uv run lint-imports\n"
"\n"
" dev:test:\n",
1,
)
s = s.replace(
" - task: dev:typecheck\n - task: dev:test\n",
" - task: dev:typecheck\n - task: dev:imports\n - task: dev:test\n",
1,
)
tf.write_text(s)
hooks = pathlib.Path(".pre-commit-config.yaml")
h = hooks.read_text()
h = h.replace(
" # pytest is a pre-push gate",
" - id: import-linter\n"
" name: import-linter\n"
" entry: uv run --no-sync lint-imports\n"
" language: system\n"
" types: [python]\n"
" pass_filenames: false\n"
" # pytest is a pre-push gate",
1,
)
hooks.write_text(h)
PY
fi
uv sync --quiet
command -v prek > /dev/null && prek install \
--hook-type pre-commit --hook-type commit-msg --hook-type pre-push
rm -f bootstrap .bootstrap-defaults
git add -A
git -c user.email="$(git config user.email ||echo bootstrap@local)" \
-c user.name="$AUTHOR" \
commit -qm "chore: bootstrap $PROJECT_NAME from python-template"
cat <<MSG
Done.
package src/$PACKAGE_NAME
command $CLI_NAME
DDD $($WANT_DDD&&echo enabled ||echo"flat (see docs how-to/add-ddd-layers)")
Next: task dev:check
MSG