Skip to content

Commit 03fb1f0

Browse files
fix: ap init fails when pnpm already exists
1 parent b4d304a commit 03fb1f0

5 files changed

Lines changed: 133 additions & 51 deletions

File tree

afterpython/doc/CONTRIBUTING.md

Lines changed: 0 additions & 7 deletions
This file was deleted.

afterpython/doc/concepts.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ favicon = "favicon.svg"
4545
logo = "logo.svg"
4646
logo_dark = "logo.svg"
4747
thumbnail = "thumbnail.png"
48+
announcement = ""
49+
# using marimo notebook (README.py) in the landing page, useful for showcasing project demos
50+
readme_py = "wasm" # marimo html export format: "wasm" or "static"
51+
execute_readme_py = false # execute README.py cells at build time and embed outputs as a preview (wasm mode only)
4852
api_reference = false # set to true to enable the pdoc-generated API reference
4953
```
5054

afterpython/doc/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
- Go from writing to **website deployment in minutes** — no need to learn any of the underlying tools
2626
- Centralize all your content in a modern, **unified project website** — from documentation to blog posts
2727
- Zero-config orchestration — Pre-configured modern tooling with sane defaults (see [](overview.md#tech-stack)), so you can start maintaining packages immediately **without learning each tool**
28+
- **⚡ Full-text search** across **ALL** your content in your website — docs, blogs, tutorials, everything
2829
- 🚧 Export content as PDF — for example, combine all blog posts into a single PDF file
29-
- 🚧 **⚡ Full-text search** across **ALL** your content in your website — docs, blogs, tutorials, everything
3030
- 🚧 **🤖 Embedded AI Chatbot** that answers questions directly using an in-browser LLM — at no cost
3131

3232

src/afterpython/cli/commands/init.py

Lines changed: 69 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ def init_py_typed():
4343
click.echo(f"Created {py_typed_path}")
4444

4545

46+
def _preflight_init(skip_website: bool) -> None:
47+
"""Validate external prerequisites before any filesystem writes, so a
48+
failure aborts cleanly instead of leaving a half-initialized project."""
49+
if not skip_website:
50+
from afterpython.tools.myst import ensure_pnpm_11
51+
from afterpython.utils import find_node_env
52+
53+
ensure_pnpm_11(find_node_env())
54+
55+
4656
@click.group(invoke_without_command=True)
4757
@click.option(
4858
"--yes",
@@ -70,48 +80,60 @@ def init(ctx, yes, skip_website: bool):
7080
from afterpython.tools.pre_commit import init_pre_commit
7181
from afterpython.tools.pyproject import init_pyproject
7282

83+
_preflight_init(skip_website)
84+
7385
paths = ctx.obj["paths"]
7486
click.echo("Initializing afterpython...")
7587
afterpython_path = paths.afterpython_path
7688
static_path = paths.static_path
7789

78-
afterpython_path.mkdir(parents=True, exist_ok=True)
79-
static_path.mkdir(parents=True, exist_ok=True)
80-
81-
init_pyproject()
82-
83-
init_afterpython()
84-
85-
if not skip_website:
86-
subprocess.run(["ap", "init", "website"])
87-
88-
# TODO: add type checking related stuff here
89-
init_py_typed()
90-
91-
create_workflow("ci")
92-
93-
if yes or click.confirm(
94-
f"\nCreate .pre-commit-config.yaml in {afterpython_path}?", default=True
95-
):
96-
init_pre_commit()
97-
98-
if yes or click.confirm(f"\nCreate ruff.toml in {afterpython_path}?", default=True):
99-
init_ruff_toml()
100-
101-
if yes or click.confirm(
102-
f"\nCreate commitizen configuration (cz.toml) in {afterpython_path} "
103-
f"and release workflow in .github/workflows/release.yml?",
104-
default=True,
105-
):
106-
init_commitizen()
107-
create_workflow("release")
108-
109-
if yes or click.confirm(
110-
"\nCreate Dependabot configuration (.github/dependabot.yml) "
111-
"to auto-update GitHub Actions versions?",
112-
default=True,
113-
):
114-
create_dependabot()
90+
try:
91+
afterpython_path.mkdir(parents=True, exist_ok=True)
92+
static_path.mkdir(parents=True, exist_ok=True)
93+
94+
init_pyproject()
95+
96+
init_afterpython()
97+
98+
if not skip_website:
99+
# check=True so a failure inside `ap init website` aborts the parent
100+
# run instead of silently continuing to write more files.
101+
subprocess.run(["ap", "init", "website"], check=True)
102+
103+
# TODO: add type checking related stuff here
104+
init_py_typed()
105+
106+
create_workflow("ci")
107+
108+
if yes or click.confirm(
109+
f"\nCreate .pre-commit-config.yaml in {afterpython_path}?", default=True
110+
):
111+
init_pre_commit()
112+
113+
if yes or click.confirm(
114+
f"\nCreate ruff.toml in {afterpython_path}?", default=True
115+
):
116+
init_ruff_toml()
117+
118+
if yes or click.confirm(
119+
f"\nCreate commitizen configuration (cz.toml) in {afterpython_path} "
120+
f"and release workflow in .github/workflows/release.yml?",
121+
default=True,
122+
):
123+
init_commitizen()
124+
create_workflow("release")
125+
126+
if yes or click.confirm(
127+
"\nCreate Dependabot configuration (.github/dependabot.yml) "
128+
"to auto-update GitHub Actions versions?",
129+
default=True,
130+
):
131+
create_dependabot()
132+
except BaseException:
133+
if afterpython_path.exists():
134+
click.echo(f"ap init failed — removing {afterpython_path}", err=True)
135+
shutil.rmtree(afterpython_path, ignore_errors=True)
136+
raise
115137

116138

117139
@init.command("website")
@@ -122,10 +144,18 @@ def init_website_subcommand():
122144
the website to an existing AfterPython project.
123145
"""
124146
from afterpython.tools.github_actions import create_workflow
125-
from afterpython.tools.myst import init_myst
147+
from afterpython.tools.myst import ensure_pnpm_11, init_myst
148+
from afterpython.utils import find_node_env
149+
150+
# Pre-flight: same rationale as `ap init` — guard the standalone entry too,
151+
# since this subcommand can be invoked directly via `ap init website`.
152+
ensure_pnpm_11(find_node_env())
126153

127154
init_faq()
128155
init_myst()
129156
click.echo(f"Initializing project website template in {ap.paths.website_path}...")
130-
subprocess.run(["ap", "update", "website"])
157+
# check=True so a network/pnpm failure inside `ap update website` aborts
158+
# the subcommand instead of silently dropping the deploy workflow on top
159+
# of a broken website install.
160+
subprocess.run(["ap", "update", "website"], check=True)
131161
create_workflow("deploy")

src/afterpython/tools/myst.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,56 @@ def _write_welcome_file(content_type: tContentType):
179179
return welcome_file
180180

181181

182+
def ensure_pnpm_11(node_env: NodeEnv) -> None:
183+
"""Ensure pnpm 11.x is available on `node_env`'s PATH. No-op if already satisfied.
184+
185+
Pinned to major 11 because an unpinned `npm install -g pnpm` previously did
186+
a silent 9→10 jump that broke `ap init` via ERR_PNPM_IGNORED_BUILDS. We
187+
pre-check the installed version so we don't redundantly invoke `npm install
188+
-g`, which fails with EEXIST when pnpm was installed by a different package
189+
manager (e.g. Homebrew) that owns the shim filenames npm wants to write.
190+
"""
191+
current: str | None = None
192+
try:
193+
result = subprocess.run(
194+
["pnpm", "--version"],
195+
env=node_env,
196+
capture_output=True,
197+
text=True,
198+
check=False,
199+
)
200+
if result.returncode == 0:
201+
current = result.stdout.strip()
202+
if current.startswith("11."):
203+
return
204+
except FileNotFoundError:
205+
pass
206+
207+
install = subprocess.run(
208+
["npm", "install", "-g", "pnpm@11"],
209+
env=node_env,
210+
capture_output=True,
211+
text=True,
212+
check=False,
213+
)
214+
if install.returncode == 0:
215+
return
216+
217+
msg = ["Failed to install pnpm@11 via npm."]
218+
if current:
219+
msg.append(f"Detected existing pnpm version: {current}")
220+
msg.append(
221+
"If pnpm is already installed via another package manager "
222+
"(e.g. Homebrew: `brew install pnpm`), npm refuses to overwrite "
223+
"its shims. Please either:\n"
224+
" - upgrade your existing pnpm to major version 11, or\n"
225+
" - uninstall it and let afterpython manage pnpm via npm."
226+
)
227+
if install.stderr:
228+
msg.append(f"\nnpm stderr:\n{install.stderr}")
229+
raise RuntimeError("\n".join(msg))
230+
231+
182232
def init_myst():
183233
"""
184234
Initialize MyST Markdown (mystmd) and myst.yml files in
@@ -189,14 +239,19 @@ def init_myst():
189239

190240
# find any existing node.js version and use it, if no, install the Node.js version specified in NODEENV_VERSION
191241
node_env: NodeEnv = find_node_env()
192-
# Pin to pnpm major version — `npm install -g pnpm` (unpinned) caused a
193-
# silent 9→10 jump that broke `ap init` via ERR_PNPM_IGNORED_BUILDS.
194-
subprocess.run(["npm", "install", "-g", "pnpm@11"], env=node_env, check=True)
242+
ensure_pnpm_11(node_env)
195243
for content_type in CONTENT_TYPES:
196244
path = ap.paths.afterpython_path / content_type
197245
print(f"Initializing MyST Markdown (mystmd) in {path.name}/ directory ...")
198246
path.mkdir(parents=True, exist_ok=True)
199-
subprocess.run(["myst", "init"], cwd=path, input="n\n", text=True, env=node_env)
247+
subprocess.run(
248+
["myst", "init"],
249+
cwd=path,
250+
input="n\n",
251+
text=True,
252+
env=node_env,
253+
check=True,
254+
)
200255
myst_yml_defaults = {
201256
"extends": "../authors.yml",
202257
"project": {

0 commit comments

Comments
 (0)