Skip to content

Commit a08eae5

Browse files
authored
fix: enables package manager command translation (#684)
1 parent 456f3f1 commit a08eae5

File tree

4 files changed

+361
-8
lines changed

4 files changed

+361
-8
lines changed

docs/features/project/bootstrap.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ It can bootstrap one or all of the following (with other options potentially bei
1313
> **Note**: Invoking bootstrap from `algokit bootstrap` is not recommended. Please prefer using `algokit project bootstrap` instead.
1414
1515
You can configure which package managers are used by default via:
16+
1617
- `algokit config py-package-manager` - Configure Python package manager (poetry or uv)
1718
- `algokit config js-package-manager` - Configure JavaScript package manager (npm or pnpm)
1819

@@ -41,6 +42,96 @@ The bootstrap command follows this precedence order when determining which packa
4142

4243
This means if you set a global preference (e.g., `algokit config py-package-manager uv`), it will be used across all projects unless explicitly overridden at the project level. Smart defaults only apply when you haven't set a preference yet.
4344

45+
## Package Manager Command Translation
46+
47+
During the bootstrap process, AlgoKit automatically translates package manager commands in your project's `.algokit.toml` file to match your configured package manager preferences. This ensures that project run commands work correctly regardless of which package manager the template was originally created with.
48+
49+
### How It Works
50+
51+
When you run `algokit project bootstrap`, if your project contains run commands in `.algokit.toml`, they will be automatically updated:
52+
53+
- **JavaScript**: `npm``pnpm` - Only semantically equivalent commands are translated
54+
- **Python**: `poetry``uv` - Only semantically equivalent commands are translated
55+
56+
### JavaScript Translation (npm ↔ pnpm)
57+
58+
**Commands that translate:**
59+
60+
- `npm install``pnpm install`
61+
- `npm run <script>``pnpm run <script>`
62+
- `npm test``pnpm test`
63+
- `npm start``pnpm start`
64+
- `npm build``pnpm build`
65+
66+
**Commands that DON'T translate** (will show a warning):
67+
68+
- `npm exec` / `npx``pnpm exec` / `pnpm dlx` - Different behavior:
69+
- `npx` searches locally in `node_modules/.bin`, then in global installs, then downloads remotely if not found
70+
- `pnpm exec` only searches locally in project dependencies (fails if not found locally)
71+
- `pnpm dlx` always fetches from remote registry (never checks local dependencies)
72+
- `npm fund` - No pnpm equivalent (pnpm does not provide funding information display)
73+
- `npm audit``pnpm audit` - May report different vulnerabilities due to differences in auditing algorithms and vulnerability databases (command translates with warning)
74+
75+
### Python Translation (poetry ↔ uv)
76+
77+
Only commands with equivalent semantics are translated:
78+
79+
**Commands that translate:**
80+
81+
- `poetry install``uv sync` (special case: different command name)
82+
- `poetry run``uv run`
83+
- `poetry add``uv add`
84+
- `poetry remove``uv remove`
85+
- `poetry lock``uv lock`
86+
- `poetry init``uv init`
87+
88+
**Commands that DON'T translate** (will show a warning):
89+
90+
- `poetry show`, `poetry config`, `poetry export`, `poetry search`, `poetry check`, `poetry publish` - No uv equivalent
91+
- `uv pip`, `uv venv`, `uv tool`, `uv python` - No poetry equivalent
92+
93+
When AlgoKit encounters a command that cannot be translated, it will:
94+
95+
1. Leave the command unchanged in `.algokit.toml`
96+
2. Display a warning message explaining that the command has no equivalent
97+
3. The command may not work when executed with `algokit project run`
98+
99+
### Example
100+
101+
Given a `.algokit.toml` with:
102+
103+
```toml
104+
[project.run]
105+
build = { commands = ["npm run build"] }
106+
create = { commands = ["npx create-next-app"] } # Different behavior in pnpm
107+
test = { commands = ["poetry run pytest"] }
108+
deps = { commands = ["poetry show --tree"] } # No uv equivalent
109+
```
110+
111+
If you've configured:
112+
113+
- JavaScript package manager: `pnpm`
114+
- Python package manager: `uv`
115+
116+
After bootstrap:
117+
118+
```toml
119+
[project.run]
120+
build = { commands = ["pnpm run build"] } # ✅ Translated
121+
create = { commands = ["npx create-next-app"] } # ⚠️ Not translated (warning shown)
122+
test = { commands = ["uv run pytest"] } # ✅ Translated
123+
deps = { commands = ["poetry show --tree"] } # ⚠️ Not translated (warning shown)
124+
```
125+
126+
You'll see warnings:
127+
128+
```
129+
⚠️ Command 'npx create-next-app' behaves differently in pnpm. Consider using 'pnpm exec' for local binaries or 'pnpm dlx' for remote packages. The command will remain unchanged.
130+
⚠️ Command 'poetry show --tree' has no direct equivalent in uv. The command will remain unchanged and may not work as expected.
131+
```
132+
133+
This approach ensures your project commands work correctly while being transparent about limitations.
134+
44135
## Usage
45136

46137
Available commands and possible usage as follows:

poetry.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/algokit/core/project/bootstrap.py

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import logging
22
import os
3+
import re
4+
import sys
35
from pathlib import Path
46

57
import click
68
import questionary
79
from packaging import version
810

11+
if sys.version_info >= (3, 11):
12+
import tomllib
13+
else:
14+
import tomli as tomllib # type: ignore[import-not-found]
15+
916
from algokit.core import proc, questionary_extensions
1017
from algokit.core.conf import ALGOKIT_CONFIG, get_algokit_config, get_current_package_version
1118
from algokit.core.config_commands.js_package_manager import (
@@ -22,6 +29,38 @@
2229

2330
ENV_TEMPLATE_PATTERN = ".env*.template"
2431
MAX_BOOTSTRAP_DEPTH = 2
32+
PKG_MANAGER_TRANSLATIONS = {
33+
JSPackageManager.PNPM: [
34+
("npm install", "pnpm install"),
35+
("npm run ", "pnpm run "),
36+
("npm test", "pnpm test"),
37+
("npm start", "pnpm start"),
38+
("npm build", "pnpm build"),
39+
],
40+
JSPackageManager.NPM: [
41+
("pnpm install", "npm install"),
42+
("pnpm run ", "npm run "),
43+
("pnpm test", "npm test"),
44+
("pnpm start", "npm start"),
45+
("pnpm build", "npm build"),
46+
],
47+
PyPackageManager.UV: [
48+
("poetry install", "uv sync"),
49+
("poetry run ", "uv run "),
50+
("poetry add ", "uv add "),
51+
("poetry remove ", "uv remove "),
52+
("poetry init", "uv init"),
53+
("poetry lock", "uv lock"),
54+
],
55+
PyPackageManager.POETRY: [
56+
("uv sync", "poetry install"),
57+
("uv run ", "poetry run "),
58+
("uv add ", "poetry add "),
59+
("uv remove ", "poetry remove "),
60+
("uv init", "poetry init"),
61+
("uv lock", "poetry lock"),
62+
],
63+
}
2564
logger = logging.getLogger(__name__)
2665

2766

@@ -167,8 +206,111 @@ def _bootstrap_javascript_project(project_dir: Path, manager: str, *, ci_mode: b
167206
bootstrap_pnpm(project_dir, ci_mode=ci_mode)
168207

169208

209+
def _translate_package_manager_in_toml(project_dir: Path, js_manager: str | None, py_manager: str | None) -> None:
210+
"""Translate package manager commands in .algokit.toml file."""
211+
toml_path = project_dir / ALGOKIT_CONFIG
212+
if not toml_path.exists():
213+
return
214+
215+
try:
216+
content = toml_path.read_text()
217+
config = tomllib.loads(content)
218+
219+
# Early exit if no run commands
220+
if not (run_commands := config.get("project", {}).get("run", {})):
221+
return
222+
223+
original = content
224+
225+
# Process all commands
226+
for command_config in run_commands.values():
227+
if not isinstance(command_config, dict):
228+
continue
229+
230+
for cmd in command_config.get("commands", []):
231+
if (translated := _translate_single_command(cmd, js_manager, py_manager)) != cmd:
232+
# Replace command preserving quotes
233+
content = re.sub(f"([\"']){re.escape(cmd)}([\"'])", f"\\1{translated}\\2", content)
234+
logger.debug(f"Translating: '{cmd}' -> '{translated}'")
235+
236+
# Write back if changed
237+
if content != original:
238+
toml_path.write_text(content)
239+
logger.info(f"Updated package manager commands in {ALGOKIT_CONFIG}")
240+
241+
except Exception as e:
242+
logger.warning(f"Failed to translate package managers in {ALGOKIT_CONFIG}: {e}")
243+
244+
245+
def _warn_incompatible_commands(cmd: str, js_manager: str | None, py_manager: str | None) -> None:
246+
"""Warn about commands that cannot be translated between package managers."""
247+
248+
# Define incompatible command prefixes
249+
py_incompatibles = {
250+
PyPackageManager.UV: {
251+
"poetry show",
252+
"poetry config",
253+
"poetry export",
254+
"poetry search",
255+
"poetry check",
256+
"poetry publish",
257+
},
258+
PyPackageManager.POETRY: {"uv pip", "uv venv", "uv tool", "uv python"},
259+
}
260+
261+
js_incompatibles = {
262+
JSPackageManager.PNPM: {"npm fund", "npm exec", "npx", "npm audit"},
263+
JSPackageManager.NPM: {"pnpm dlx", "pnpm exec", "pnpm audit"},
264+
}
265+
266+
# Check for incompatible Python commands
267+
if py_manager:
268+
py_manager_enum = PyPackageManager(py_manager)
269+
if py_manager_enum in py_incompatibles:
270+
for prefix in py_incompatibles[py_manager_enum]:
271+
if cmd.startswith(prefix):
272+
logger.warning(
273+
f"⚠️ Command '{cmd}' may not be compatible with {py_manager}. "
274+
"The command will remain unchanged and may not work as expected."
275+
)
276+
return
277+
278+
# Check for incompatible JavaScript commands
279+
if js_manager:
280+
js_manager_enum = JSPackageManager(js_manager)
281+
if js_manager_enum in js_incompatibles:
282+
for prefix in js_incompatibles[js_manager_enum]:
283+
if cmd.startswith(prefix):
284+
logger.warning(
285+
f"⚠️ Command '{cmd}' may not be compatible with {js_manager}. "
286+
"The command will remain unchanged and may not work as expected."
287+
)
288+
return
289+
290+
291+
def _translate_single_command(cmd: str, js_manager: str | None, py_manager: str | None) -> str:
292+
"""Minimal translation - only for semantically equivalent commands."""
293+
if not cmd:
294+
return cmd
295+
296+
_warn_incompatible_commands(cmd, js_manager, py_manager)
297+
298+
for manager in (js_manager, py_manager):
299+
if manager and (translations := PKG_MANAGER_TRANSLATIONS.get(manager)):
300+
for old, new in translations:
301+
if old.endswith(" "):
302+
if cmd.startswith(old):
303+
return new + cmd[len(old) :]
304+
305+
elif cmd == old or cmd.startswith(f"{old} "):
306+
remainder = cmd[len(old) :] if cmd != old else ""
307+
return new + remainder
308+
309+
return cmd
310+
311+
170312
def bootstrap_any(project_dir: Path, *, ci_mode: bool) -> None:
171-
"""Bootstrap a project with elegant package manager selection."""
313+
"""Bootstrap a project with automatic package manager selection."""
172314

173315
logger.debug(f"Checking {project_dir} for bootstrapping needs")
174316

@@ -177,15 +319,23 @@ def bootstrap_any(project_dir: Path, *, ci_mode: bool) -> None:
177319
logger.debug("Running `algokit project bootstrap env`")
178320
bootstrap_env(project_dir, ci_mode=ci_mode)
179321

322+
# Determine package managers
323+
js_manager = None
324+
py_manager = None
325+
180326
# Python projects
181327
if _has_python_project(project_dir):
182-
manager = _determine_python_package_manager(project_dir)
183-
_bootstrap_python_project(project_dir, manager)
328+
py_manager = _determine_python_package_manager(project_dir)
329+
_bootstrap_python_project(project_dir, py_manager)
184330

185331
# JavaScript projects
186332
if _has_javascript_project(project_dir):
187-
manager = _determine_javascript_package_manager(project_dir)
188-
_bootstrap_javascript_project(project_dir, manager, ci_mode=ci_mode)
333+
js_manager = _determine_javascript_package_manager(project_dir)
334+
_bootstrap_javascript_project(project_dir, js_manager, ci_mode=ci_mode)
335+
336+
# Translate package manager commands in .algokit.toml
337+
if js_manager or py_manager:
338+
_translate_package_manager_in_toml(project_dir, js_manager, py_manager)
189339

190340

191341
def bootstrap_any_including_subdirs( # noqa: PLR0913

0 commit comments

Comments
 (0)