fixpm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fixpm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """fixpm — interactive command fixer for npm / npx / pnpm / yarn."""
2
+
3
+ __version__ = "0.1.0"
fixpm/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from fixpm.cli import app
2
+
3
+ app()
fixpm/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ """CLI entry point.
2
+
3
+ Modes:
4
+ fixpm fix $FIXPM_LAST_COMMAND (set by shell hook)
5
+ fixpm <command words...> fix an explicit command line
6
+ fixpm --dry-run <cmd> print fixes without prompting/executing
7
+ fixpm --init zsh|bash emit the hook script for eval "$(...)"
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ from importlib import resources
16
+
17
+ import typer
18
+
19
+ from . import __version__
20
+ from .corrector import get_corrections
21
+ from .interactive import choose
22
+ from .rules import all_specs, spec_for_binary
23
+ from .rules.base import KIND_LABEL
24
+
25
+ app = typer.Typer(
26
+ add_completion=False,
27
+ context_settings={"help_option_names": ["-h", "--help"]},
28
+ help="Fix broken npm / npx / pnpm / yarn commands.",
29
+ )
30
+
31
+ ENV_COMMAND = "FIXPM_LAST_COMMAND"
32
+ ENV_EXIT_CODE = "FIXPM_LAST_EXIT_CODE"
33
+
34
+ _HOOKS = {"bash": "fixpm.bash", "zsh": "fixpm.zsh"}
35
+
36
+
37
+ def _version_callback(value: bool) -> None:
38
+ if value:
39
+ typer.echo(f"fixpm {__version__}")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback(invoke_without_command=True)
44
+ def main(
45
+ ctx: typer.Context,
46
+ command: list[str] | None = typer.Argument(
47
+ None, help="Failed command to fix. Reads $FIXPM_LAST_COMMAND when omitted."
48
+ ),
49
+ manager: str | None = typer.Option(
50
+ None, "--manager", "-m",
51
+ help="Force a package manager instead of auto-detecting.",
52
+ ),
53
+ dry_run: bool = typer.Option(
54
+ False, "--dry-run", help="Print fixes without prompting or executing."
55
+ ),
56
+ version: bool = typer.Option(
57
+ False, "--version", "-V", callback=_version_callback, is_eager=True,
58
+ help="Show version and exit.",
59
+ ),
60
+ init_shell: str | None = typer.Option(
61
+ None, "--init",
62
+ help='Emit a hook script for the given shell ("bash" or "zsh"). '
63
+ 'Use inside eval: eval "$(fixpm --init zsh)".',
64
+ ),
65
+ ) -> None:
66
+ if init_shell is not None:
67
+ _emit_hook(init_shell)
68
+ return
69
+ if ctx.invoked_subcommand is not None:
70
+ return
71
+
72
+ text = " ".join(command).strip() if command \
73
+ else os.environ.get(ENV_COMMAND, "").strip()
74
+ if not text:
75
+ typer.secho("No command provided.", fg="red")
76
+ typer.echo(
77
+ 'Run `fixpm <failed command>` or install a shell hook first: '
78
+ '`fixpm --init zsh`.'
79
+ )
80
+ raise typer.Exit(code=1)
81
+
82
+ spec = None
83
+ if manager:
84
+ spec = spec_for_binary(manager)
85
+ if spec is None:
86
+ names = ", ".join(sorted({s.binaries[0] for s in all_specs()}))
87
+ raise typer.BadParameter(f"unknown manager {manager!r}; pick one of: {names}")
88
+
89
+ corrections = get_corrections(text, spec=spec)
90
+ if not corrections:
91
+ typer.secho(f"No fix found for: {text}", fg="yellow")
92
+ raise typer.Exit(code=1)
93
+
94
+ if dry_run:
95
+ for c in corrections:
96
+ typer.echo(f" [{KIND_LABEL[c.kind]}] {c.command}")
97
+ return
98
+
99
+ choice = choose(corrections)
100
+ if choice is None:
101
+ typer.echo("Cancelled.")
102
+ raise typer.Exit(code=1)
103
+
104
+ typer.secho(f"-> {choice.command}", fg="cyan", bold=True)
105
+ if not choice.executable:
106
+ typer.echo("This fix needs manual arguments — copy it and fill them in.")
107
+ raise typer.Exit(code=0)
108
+
109
+ raise typer.Exit(code=subprocess.run(choice.command, shell=True).returncode)
110
+
111
+
112
+ def _emit_hook(shell: str) -> None:
113
+ filename = _HOOKS.get(shell)
114
+ if filename is None:
115
+ supported = ", ".join(sorted(_HOOKS))
116
+ typer.secho(f"Unsupported shell {shell!r}. Supported: {supported}",
117
+ fg="red", err=True)
118
+ raise typer.Exit(code=1)
119
+ script = (resources.files("fixpm.shell") / filename).read_text(encoding="utf-8")
120
+ # Write LF-only bytes directly: Windows pipes translate "\n" to "\r\n",
121
+ # and a unix shell sourcing CRLF text ends up with broken hook definitions
122
+ # (functions named "...{\r"). Git Bash/MSYS silently tolerates CRLF, WSL
123
+ # and native Linux do not — so the raw byte stream must be LF.
124
+ sys.stdout.buffer.write(script.replace("\r\n", "\n").encode("utf-8"))
125
+ sys.stdout.buffer.flush()
126
+ rc_file = "~/.zshrc" if shell == "zsh" else "~/.bashrc"
127
+ typer.secho(f"# Add this line to your {rc_file}: "
128
+ f'eval "$(fixpm --init {shell})"', dim=True, err=True)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ app()
fixpm/corrector.py ADDED
@@ -0,0 +1,118 @@
1
+ """Turn analyzer issues into concrete, ranked replacement commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+
7
+ from .detector import analyze, tokenize
8
+ from .distance import similarity
9
+ from .packages import NpmRegistry, base_name, format_downloads
10
+ from .rules.base import Correction, Issue, IssueKind, ManagerSpec
11
+
12
+ _client: NpmRegistry | None = None
13
+
14
+ # Placeholder shown when a fix needs manual input (never auto-executed).
15
+ _PLACEHOLDERS = {
16
+ "run": "<script>",
17
+ "exec": "<package> [args...]",
18
+ "dlx": "<package> [args...]",
19
+ "add": "<package>",
20
+ "install": "<package>",
21
+ "remove": "<package>",
22
+ "uninstall": "<package>",
23
+ "why": "<package>",
24
+ "info": "<package>",
25
+ "create": "<initializer>",
26
+ "config": "<key> <value>",
27
+ "cache": "<action>",
28
+ "pkg": "<action>",
29
+ }
30
+
31
+
32
+ def _default_client() -> NpmRegistry:
33
+ global _client
34
+ if _client is None:
35
+ _client = NpmRegistry()
36
+ return _client
37
+
38
+
39
+ def get_corrections(
40
+ text: str,
41
+ *,
42
+ spec: ManagerSpec | None = None,
43
+ client: NpmRegistry | None = None,
44
+ ) -> list[Correction]:
45
+ """Top fix suggestions (max 3), best first."""
46
+ tokens, spec, issues = analyze(text, spec)
47
+ if spec is None or not issues:
48
+ return []
49
+ client = client or _default_client()
50
+ corrections: list[Correction] = []
51
+ for issue in issues:
52
+ corrections.extend(_fix_issue(tokens, spec, issue, client))
53
+ best: dict[str, Correction] = {}
54
+ for c in corrections:
55
+ if c.command not in best or c.score > best[c.command].score:
56
+ best[c.command] = c
57
+ ranked = sorted(best.values(), key=lambda c: (-c.score, c.command))
58
+ return ranked[:3]
59
+
60
+
61
+ def _ratio_score(ratio: float) -> float:
62
+ return round(0.5 + 0.45 * ratio, 3)
63
+
64
+
65
+ def _replace(tokens: list[str], index: int, new_token: str) -> str:
66
+ patched = list(tokens)
67
+ patched[index] = new_token
68
+ return shlex.join(patched)
69
+
70
+
71
+ def _fix_issue(tokens: list[str], spec: ManagerSpec, issue: Issue,
72
+ client: NpmRegistry) -> list[Correction]:
73
+ kind = issue.kind
74
+
75
+ if kind is IssueKind.SUBCOMMAND_TYPO:
76
+ out = []
77
+ hinted = spec.typo_hints.get(issue.token.lower())
78
+ for cand in issue.candidates[:3]:
79
+ score = (0.95 if cand == hinted
80
+ else _ratio_score(similarity(issue.token.lower(), cand)))
81
+ out.append(Correction(_replace(tokens, issue.index, cand),
82
+ kind, score, issue.message))
83
+ return out
84
+
85
+ if kind is IssueKind.FLAG_TYPO:
86
+ return [
87
+ Correction(_replace(tokens, issue.index, cand), kind,
88
+ _ratio_score(similarity(issue.token, cand)),
89
+ issue.message)
90
+ for cand in issue.candidates[:2]
91
+ ]
92
+
93
+ if kind is IssueKind.MISSING_DASHES:
94
+ return [Correction(_replace(tokens, issue.index, issue.candidates[0]),
95
+ kind, 0.9, issue.message)]
96
+
97
+ if kind is IssueKind.ARG_REQUIRED:
98
+ placeholder = _PLACEHOLDERS.get(issue.token, "<arguments>")
99
+ return [Correction(f"{shlex.join(tokens)} {placeholder}", kind, 0.6,
100
+ issue.message, False)]
101
+
102
+ if kind is IssueKind.PACKAGE_TYPO:
103
+ name = base_name(issue.token)
104
+ out = []
105
+ for s in client.suggest(name)[:3]:
106
+ if s.name.lower() == name.lower():
107
+ continue
108
+ new_token = issue.token.replace(name, s.name, 1)
109
+ detail = (f"package '{name}' -> '{s.name}' "
110
+ f"({format_downloads(s.weekly_downloads)}/week)")
111
+ out.append(Correction(_replace(tokens, issue.index, new_token),
112
+ kind, s.score, detail))
113
+ return out
114
+
115
+ return []
116
+
117
+
118
+ __all__ = ["get_corrections", "tokenize"]
fixpm/detector.py ADDED
@@ -0,0 +1,223 @@
1
+ """Classify what went wrong in a failed package-manager command line.
2
+
3
+ One generic engine consumes every ``ManagerSpec``; per-manager knowledge lives
4
+ entirely in the rule tables under ``fixpm.rules``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import shlex
11
+
12
+ from .distance import rank
13
+ from .rules import spec_for_binary
14
+ from .rules.base import Issue, IssueKind, ManagerSpec
15
+
16
+ _PKG_RE = re.compile(
17
+ r"^(@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$", re.IGNORECASE
18
+ )
19
+
20
+
21
+ def tokenize(text: str) -> list[str]:
22
+ try:
23
+ return shlex.split(text)
24
+ except ValueError:
25
+ return text.split()
26
+
27
+
28
+ def looks_like_package(token: str) -> bool:
29
+ """Conservative npm-package-name shape check (paths, globs and files out)."""
30
+ if not token or any(c in token for c in "*~=\\"):
31
+ return False
32
+ if token.startswith((".", "~")):
33
+ return False
34
+ if "/" in token and not token.startswith("@"):
35
+ return False
36
+ return bool(_PKG_RE.match(token))
37
+
38
+
39
+ def _nearest(token: str, pool: tuple[str, ...], top: int = 2,
40
+ min_score: float = 0.6) -> list[str]:
41
+ return [c for c, _ in rank(token.lower(), [p.lower() for p in sorted(pool)],
42
+ top=top, min_score=min_score)]
43
+
44
+
45
+ def _sub_candidates(token: str, spec: ManagerSpec) -> list[str]:
46
+ hint = spec.typo_hints.get(token.lower())
47
+ if hint:
48
+ return [hint]
49
+ return _nearest(token, tuple(spec.vocabulary), top=3, min_score=0.55)
50
+
51
+
52
+ def analyze(
53
+ text: str, spec: ManagerSpec | None = None
54
+ ) -> tuple[list[str], ManagerSpec | None, list[Issue]]:
55
+ """Return ``(tokens, spec_or_None, issues)`` for a failed command line."""
56
+ tokens = tokenize(text)
57
+ located = _locate(tokens, spec)
58
+ if located is None:
59
+ return tokens, None, []
60
+ index, spec = located
61
+ if spec.package_first:
62
+ return tokens, spec, _analyze_package_first(spec, tokens, index + 1)
63
+ return tokens, spec, _analyze_subcommand(spec, tokens, index + 1)
64
+
65
+
66
+ def _locate(tokens: list[str],
67
+ spec: ManagerSpec | None) -> tuple[int, ManagerSpec] | None:
68
+ if spec is not None:
69
+ for i, t in enumerate(tokens[:4]):
70
+ if t in spec.binaries:
71
+ return i, spec
72
+ return None
73
+ for i, t in enumerate(tokens[:4]):
74
+ found = spec_for_binary(t)
75
+ if found is not None:
76
+ return i, found
77
+ return None
78
+
79
+
80
+ def _analyze_subcommand(spec: ManagerSpec, tokens: list[str],
81
+ start: int) -> list[Issue]:
82
+ issues: list[Issue] = []
83
+ rest = tokens[start:]
84
+
85
+ # Leading global flags, e.g. `npm --prefix ./x install`
86
+ i = 0
87
+ while i < len(rest) and rest[i].startswith("-"):
88
+ flag = rest[i]
89
+ if flag in spec.value_flags and i + 1 < len(rest):
90
+ i += 2
91
+ continue
92
+ if flag not in spec.global_flags:
93
+ near = _nearest(flag, spec.global_flags)
94
+ if near:
95
+ issues.append(Issue(
96
+ IssueKind.FLAG_TYPO, flag, start + i,
97
+ f"Unknown global flag '{flag}' — did you mean {near[0]}?",
98
+ tuple(near),
99
+ ))
100
+ i += 1
101
+
102
+ if i >= len(rest):
103
+ return issues
104
+
105
+ sub = rest[i]
106
+ sub_abs = start + i
107
+ canon = spec.canonical(sub)
108
+
109
+ if canon is None:
110
+ candidates = _sub_candidates(sub, spec)
111
+ if candidates:
112
+ issues.append(Issue(
113
+ IssueKind.SUBCOMMAND_TYPO, sub, sub_abs,
114
+ f"'{sub}' is not a {spec.name} command",
115
+ tuple(candidates),
116
+ ))
117
+ # One fix at a time: stop validating after an unknown command.
118
+ return issues
119
+
120
+ # Chain commands like `yarn global add <pkg>` — absorb the sub-verb.
121
+ extra = 0
122
+ if canon == "global" and spec.sub_verbs and \
123
+ i + 1 < len(rest) and rest[i + 1] in spec.sub_verbs:
124
+ extra = 1
125
+ canon = f"global {rest[i + 1]}"
126
+ effective = canon.rsplit(" ", 1)[-1]
127
+ allowed = spec.flags.get(effective, ())
128
+
129
+ positional: list[tuple[int, str]] = []
130
+ j = i + 1 + extra
131
+ while j < len(rest):
132
+ token = rest[j]
133
+ if token == "--": # passthrough separator: everything after is exempt
134
+ break
135
+ if token.startswith("-"):
136
+ if allowed and token not in allowed and token not in spec.global_flags:
137
+ near = _nearest(token, allowed)
138
+ if near:
139
+ issues.append(Issue(
140
+ IssueKind.FLAG_TYPO, token, start + j,
141
+ f"Unknown flag '{token}' for `{spec.name} "
142
+ f"{effective}` — did you mean {near[0]}?",
143
+ tuple(near),
144
+ ))
145
+ if token in spec.value_flags:
146
+ j += 1 # skip the consumed value
147
+ else:
148
+ positional.append((j, token))
149
+ j += 1
150
+
151
+ # `npm install express save-dev` -> missing dashes on `save-dev`
152
+ dashless = {f.lstrip("-"): f for f in allowed}
153
+ flagged: set[int] = set()
154
+ for jj, token in positional:
155
+ if len(token) > 1 and token in dashless:
156
+ issues.append(Issue(
157
+ IssueKind.MISSING_DASHES, token, start + jj,
158
+ f"Missing flag prefix — did you mean {dashless[token]}?",
159
+ (dashless[token],),
160
+ ))
161
+ flagged.add(jj)
162
+ positional = [(jj, t) for jj, t in positional if jj not in flagged]
163
+
164
+ if canon in spec.arg_required and not positional and extra == 0:
165
+ issues.append(Issue(
166
+ IssueKind.ARG_REQUIRED, sub, sub_abs,
167
+ f"`{spec.name} {sub}` needs an argument "
168
+ f"(see `{spec.name} help {effective}`)",
169
+ (),
170
+ ))
171
+
172
+ if canon in spec.package_commands or canon.startswith("global "):
173
+ checked = 0
174
+ for jj, token in positional:
175
+ if checked >= 2:
176
+ break
177
+ if token.lower() in spec.vocabulary or not looks_like_package(token):
178
+ continue
179
+ issues.append(Issue(
180
+ IssueKind.PACKAGE_TYPO, token, start + jj,
181
+ f"Check package name '{token}'",
182
+ (),
183
+ ))
184
+ checked += 1
185
+ return issues
186
+
187
+
188
+ def _analyze_package_first(spec: ManagerSpec, tokens: list[str],
189
+ start: int) -> list[Issue]:
190
+ issues: list[Issue] = []
191
+ rest = tokens[start:]
192
+ i = 0
193
+ while i < len(rest) and rest[i].startswith("-"):
194
+ flag = rest[i]
195
+ if flag in spec.value_flags and i + 1 < len(rest):
196
+ i += 2
197
+ continue
198
+ if flag not in spec.global_flags:
199
+ near = _nearest(flag, spec.global_flags)
200
+ if near:
201
+ issues.append(Issue(
202
+ IssueKind.FLAG_TYPO, flag, start + i,
203
+ f"Unknown flag '{flag}' — did you mean {near[0]}?",
204
+ tuple(near),
205
+ ))
206
+ i += 1
207
+
208
+ if i >= len(rest):
209
+ issues.append(Issue(
210
+ IssueKind.ARG_REQUIRED, spec.name, start - 1,
211
+ f"`{spec.name}` needs a package or binary to run",
212
+ (),
213
+ ))
214
+ return issues
215
+
216
+ pkg = rest[i]
217
+ if looks_like_package(pkg):
218
+ issues.append(Issue(
219
+ IssueKind.PACKAGE_TYPO, pkg, start + i,
220
+ f"Check package name '{pkg}'",
221
+ (),
222
+ ))
223
+ return issues
fixpm/distance.py ADDED
@@ -0,0 +1,63 @@
1
+ """String-similarity primitives.
2
+
3
+ We use optimal-string-alignment (a restricted Damerau-Levenshtein) so common
4
+ transpositions ("svae" -> "save") cost 1, not 2. Pure stdlib, zero deps.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def edit_distance(a: str, b: str) -> int:
11
+ if a == b:
12
+ return 0
13
+ la, lb = len(a), len(b)
14
+ if la == 0 or lb == 0:
15
+ return la + lb
16
+ prev2: list[int] | None = None
17
+ prev = list(range(lb + 1))
18
+ for i in range(1, la + 1):
19
+ cur = [i] + [0] * lb
20
+ ca = a[i - 1]
21
+ for j in range(1, lb + 1):
22
+ cost = 0 if ca == b[j - 1] else 1
23
+ v = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
24
+ if (
25
+ prev2 is not None
26
+ and i > 1
27
+ and j > 1
28
+ and ca == b[j - 2]
29
+ and a[i - 2] == b[j - 1]
30
+ ):
31
+ v = min(v, prev2[j - 2] + 1)
32
+ cur[j] = v
33
+ prev2, prev = prev, cur
34
+ return prev[lb]
35
+
36
+
37
+ def similarity(a: str, b: str) -> float:
38
+ """Normalized similarity in [0, 1]; 1 means identical."""
39
+ m = max(len(a), len(b))
40
+ if m == 0:
41
+ return 1.0
42
+ return 1.0 - edit_distance(a, b) / m
43
+
44
+
45
+ def rank(
46
+ target: str,
47
+ candidates: list[str],
48
+ *,
49
+ top: int = 3,
50
+ min_score: float = 0.6,
51
+ ) -> list[tuple[str, float]]:
52
+ """Best matches for *target*, sorted by score desc then length/name asc."""
53
+ seen: set[str] = set()
54
+ scored: list[tuple[str, float]] = []
55
+ for c in candidates:
56
+ if c in seen:
57
+ continue
58
+ seen.add(c)
59
+ s = similarity(target, c)
60
+ if s >= min_score:
61
+ scored.append((c, s))
62
+ scored.sort(key=lambda t: (-t[1], len(t[0]), t[0]))
63
+ return scored[:top]
fixpm/interactive.py ADDED
@@ -0,0 +1,28 @@
1
+ """Arrow-key interactive selection (questionary / prompt_toolkit)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import questionary
6
+ from questionary import Choice
7
+
8
+ from .rules.base import KIND_LABEL, Correction
9
+
10
+
11
+ def choose(corrections: list[Correction]) -> Correction | None:
12
+ choices = [
13
+ Choice(
14
+ title=f"{c.command} · {KIND_LABEL[c.kind]} · {int(c.score * 100)}%",
15
+ value=c,
16
+ )
17
+ for c in corrections
18
+ ]
19
+ choices.append(Choice(title="Skip — do nothing", value=None))
20
+ try:
21
+ return questionary.select(
22
+ "Apply a fix:",
23
+ choices=choices,
24
+ qmark="?",
25
+ instruction="(↑/↓ move · enter select · ctrl+c cancel)",
26
+ ).ask()
27
+ except (KeyboardInterrupt, EOFError):
28
+ return None