rlsbl 0.0.1 → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/bin/cli.js +16 -0
- package/package.json +22 -3
- package/rlsbl/__init__.py +174 -1
- package/rlsbl/__main__.py +4 -0
- package/rlsbl/commands/__init__.py +0 -0
- package/rlsbl/commands/check_name.py +184 -0
- package/rlsbl/commands/init_cmd.py +321 -0
- package/rlsbl/commands/release.py +179 -0
- package/rlsbl/commands/status.py +76 -0
- package/rlsbl/registries/__init__.py +5 -0
- package/rlsbl/registries/npm.py +120 -0
- package/rlsbl/registries/pypi.py +172 -0
- package/rlsbl/utils.py +124 -0
- package/templates/npm/ci.yml.tpl +22 -0
- package/templates/npm/publish.yml.tpl +22 -0
- package/templates/pypi/ci.yml.tpl +20 -0
- package/templates/pypi/publish.yml.tpl +18 -0
- package/templates/shared/CHANGELOG.md.tpl +5 -0
- package/templates/shared/CLAUDE.md.tpl +20 -0
- package/templates/shared/LICENSE.tpl +21 -0
- package/templates/shared/check-prs.sh.tpl +10 -0
- package/templates/shared/claude-settings.json.tpl +15 -0
- package/templates/shared/gitignore.tpl +13 -0
- package/templates/shared/pre-push-hook.sh.tpl +42 -0
- package/templates/shared/pre-release.sh.tpl +13 -0
- package/templates/shared/record-gif.sh.tpl +34 -0
- package/pyproject.toml +0 -11
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 smm-h
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# rlsbl
|
|
2
|
+
|
|
3
|
+
Release orchestration and project scaffolding CLI for npm and PyPI.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
From npm:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm i -g rlsbl
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
From PyPI (requires Node.js 18+):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
uv tool install rlsbl
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
rlsbl scaffold
|
|
23
|
+
rlsbl release minor
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
All commands work at the top level -- registries are auto-detected from project files (`package.json`, `pyproject.toml`). Use the registry-specific form (`rlsbl <registry> <command>`) only when you need to target a single registry.
|
|
29
|
+
|
|
30
|
+
### scaffold [--force] [--update]
|
|
31
|
+
|
|
32
|
+
Scaffolds CI/CD infrastructure and release tooling for all detected registries.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
rlsbl scaffold
|
|
36
|
+
rlsbl npm scaffold # target npm only
|
|
37
|
+
rlsbl pypi scaffold --force # overwrite existing files
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Context-aware behavior when files already exist (without `--force`):
|
|
41
|
+
|
|
42
|
+
| File | Behavior |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `CLAUDE.md` | Appends rlsbl sections if the marker is not present |
|
|
45
|
+
| `.gitignore` | Merges missing entries from the template |
|
|
46
|
+
| `.github/workflows/ci.yml` | Preserves existing file, prints a note to review manually |
|
|
47
|
+
| All others | Skipped |
|
|
48
|
+
|
|
49
|
+
### release [patch|minor|major] [--dry-run] [--quiet]
|
|
50
|
+
|
|
51
|
+
Bumps version, commits, pushes, and creates a GitHub Release. Defaults to `patch`.
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
rlsbl release minor
|
|
55
|
+
rlsbl npm release major --dry-run
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The version is synced across all detected project files (`package.json`, `pyproject.toml`) regardless of which registry is primary.
|
|
59
|
+
|
|
60
|
+
If `scripts/pre-release.sh` exists, it runs before any changes are made. A non-zero exit aborts the release.
|
|
61
|
+
|
|
62
|
+
### status
|
|
63
|
+
|
|
64
|
+
Shows project status: package name, version (per registry), git branch, last tag, working tree state, changelog coverage, and CI workflow presence.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
rlsbl status
|
|
68
|
+
rlsbl pypi status
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### check-name \<name\>
|
|
72
|
+
|
|
73
|
+
Checks name availability on both npm and PyPI, and warns about confusingly similar names.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
rlsbl check-name my-cool-lib
|
|
77
|
+
rlsbl npm check-name my-cool-lib # npm only
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
npm checks variant spellings (hyphens, underscores, dots, no separator). PyPI normalizes per PEP 503 and checks common alternatives.
|
|
81
|
+
|
|
82
|
+
Global flags: `--help`, `--version`.
|
|
83
|
+
|
|
84
|
+
## Release flow
|
|
85
|
+
|
|
86
|
+
When you run `release`, the following happens in order:
|
|
87
|
+
|
|
88
|
+
1. Verifies `gh` CLI is installed and authenticated
|
|
89
|
+
2. Checks that the git working tree is clean
|
|
90
|
+
3. Reads the current version from the primary project file
|
|
91
|
+
4. Computes the new version and confirms the git tag does not already exist
|
|
92
|
+
5. Validates that `CHANGELOG.md` contains a `## <new-version>` section
|
|
93
|
+
6. Runs `scripts/pre-release.sh` if present (non-zero exit aborts)
|
|
94
|
+
7. Writes the new version to the primary project file
|
|
95
|
+
8. Syncs the new version to all other detected project files
|
|
96
|
+
9. Commits the version bump (uses `safegit` if available, otherwise `git`)
|
|
97
|
+
10. Pushes the branch to `origin`
|
|
98
|
+
11. Creates a GitHub Release tagged `v<new-version>` with the changelog entry as notes
|
|
99
|
+
12. The GitHub Release triggers `publish.yml`, which publishes to the registry
|
|
100
|
+
|
|
101
|
+
## What scaffold creates
|
|
102
|
+
|
|
103
|
+
| File | Source | Purpose |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `.github/workflows/ci.yml` | Registry-specific | CI workflow (lint, test) |
|
|
106
|
+
| `.github/workflows/publish.yml` | Registry-specific | Publish on GitHub Release (OIDC) |
|
|
107
|
+
| `CHANGELOG.md` | Shared | Version changelog |
|
|
108
|
+
| `LICENSE` | Shared | MIT license (author and year filled in) |
|
|
109
|
+
| `.gitignore` | Shared | Standard ignores for the ecosystem |
|
|
110
|
+
| `CLAUDE.md` | Shared | AI assistant instructions |
|
|
111
|
+
| `.claude/settings.json` | Shared | Claude Code settings |
|
|
112
|
+
| `scripts/check-prs.sh` | Shared | PR review helper |
|
|
113
|
+
| `scripts/pre-release.sh` | Shared | Pre-release hook (runs before each release) |
|
|
114
|
+
| `scripts/record-gif.sh` | Shared | Terminal recording helper |
|
|
115
|
+
| `scripts/pre-push-hook.sh` | Shared | Pre-push changelog enforcement |
|
|
116
|
+
|
|
117
|
+
All `.sh` files in `scripts/` are made executable automatically. The pre-push hook is installed into `.git/hooks/pre-push` during scaffold.
|
|
118
|
+
|
|
119
|
+
## Pre-push hook
|
|
120
|
+
|
|
121
|
+
The scaffolded `scripts/pre-push-hook.sh` is installed as a git pre-push hook during `scaffold`. It prevents pushing when `CHANGELOG.md` lacks an entry for the current version.
|
|
122
|
+
|
|
123
|
+
How it works:
|
|
124
|
+
|
|
125
|
+
1. Detects project type (`package.json` or `pyproject.toml`)
|
|
126
|
+
2. Extracts the current version
|
|
127
|
+
3. Checks that `CHANGELOG.md` contains a heading `## <version>`
|
|
128
|
+
4. Blocks the push with an error if the entry is missing
|
|
129
|
+
|
|
130
|
+
To reinstall manually:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
cp scripts/pre-push-hook.sh .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## First publish
|
|
137
|
+
|
|
138
|
+
The first version must be published manually before CI can take over:
|
|
139
|
+
|
|
140
|
+
| Registry | Manual first publish | Then configure |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| npm | Add an `NPM_TOKEN` secret to your GitHub repo (Settings > Secrets > Actions), then push a release | CI handles subsequent publishes |
|
|
143
|
+
| PyPI | Run `uv publish` | Set up [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) on pypi.org |
|
|
144
|
+
|
|
145
|
+
After configuration, all subsequent releases are handled by CI when `rlsbl release` creates a GitHub Release.
|
|
146
|
+
|
|
147
|
+
## Requirements
|
|
148
|
+
|
|
149
|
+
- Node 18+
|
|
150
|
+
- [GitHub CLI](https://cli.github.com) (`gh`), installed and authenticated
|
|
151
|
+
- git
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { execFileSync, spawnSync } = require("child_process");
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
execFileSync("python3", ["--version"], { stdio: "pipe" });
|
|
8
|
+
} catch {
|
|
9
|
+
console.error("rlsbl requires Python 3.11+. Install from https://python.org/");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const result = spawnSync("python3", ["-m", "rlsbl", ...process.argv.slice(2)], {
|
|
14
|
+
stdio: "inherit",
|
|
15
|
+
});
|
|
16
|
+
process.exit(result.status ?? 1);
|
package/package.json
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rlsbl",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Release orchestration and project scaffolding for npm and PyPI",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"
|
|
6
|
+
"bin": {
|
|
7
|
+
"rlsbl": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"rlsbl/",
|
|
12
|
+
"templates/"
|
|
13
|
+
],
|
|
7
14
|
"repository": {
|
|
8
15
|
"type": "git",
|
|
9
16
|
"url": "git+https://github.com/smm-h/rlsbl.git"
|
|
10
|
-
}
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"release",
|
|
23
|
+
"npm",
|
|
24
|
+
"pypi",
|
|
25
|
+
"publish",
|
|
26
|
+
"cli",
|
|
27
|
+
"scaffold"
|
|
28
|
+
],
|
|
29
|
+
"author": "smm-h"
|
|
11
30
|
}
|
package/rlsbl/__init__.py
CHANGED
|
@@ -1 +1,174 @@
|
|
|
1
|
-
"""rlsbl:
|
|
1
|
+
"""rlsbl: Release orchestration and project scaffolding for npm and PyPI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from importlib.metadata import version as _get_version
|
|
8
|
+
__version__ = _get_version("rlsbl")
|
|
9
|
+
except Exception:
|
|
10
|
+
__version__ = "unknown"
|
|
11
|
+
|
|
12
|
+
REGISTRIES = ("npm", "pypi")
|
|
13
|
+
COMMANDS = ("release", "status", "scaffold", "check-name")
|
|
14
|
+
COMMAND_ALIASES = {"init": "scaffold"}
|
|
15
|
+
|
|
16
|
+
HELP = f"""\
|
|
17
|
+
rlsbl v{__version__} -- Release orchestration and project scaffolding for npm and PyPI
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
rlsbl release [patch|minor|major] [--dry-run] [--quiet] Orchestrate a release
|
|
21
|
+
rlsbl status Show project status
|
|
22
|
+
rlsbl scaffold [--force] [--update] Scaffold release infrastructure
|
|
23
|
+
rlsbl check-name <name> Check name availability
|
|
24
|
+
|
|
25
|
+
Registry-specific (when you need to target one):
|
|
26
|
+
rlsbl <registry> <command> [args...]
|
|
27
|
+
|
|
28
|
+
Registries: {', '.join(REGISTRIES)}"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_registries():
|
|
32
|
+
"""Detect all registries that have a project file in the current directory.
|
|
33
|
+
|
|
34
|
+
Returns a list, e.g. ["npm"], ["pypi"], or ["npm", "pypi"].
|
|
35
|
+
"""
|
|
36
|
+
found = []
|
|
37
|
+
if os.path.exists("package.json"):
|
|
38
|
+
found.append("npm")
|
|
39
|
+
if os.path.exists("pyproject.toml"):
|
|
40
|
+
found.append("pypi")
|
|
41
|
+
return found
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_args(argv):
|
|
45
|
+
"""Parse sys.argv into positional args and flags."""
|
|
46
|
+
raw = argv[1:]
|
|
47
|
+
positional = []
|
|
48
|
+
flags = {}
|
|
49
|
+
|
|
50
|
+
for arg in raw:
|
|
51
|
+
if arg.startswith("--"):
|
|
52
|
+
flags[arg[2:]] = True
|
|
53
|
+
elif arg.startswith("-") and len(arg) == 2:
|
|
54
|
+
flags[arg[1:]] = True
|
|
55
|
+
else:
|
|
56
|
+
positional.append(arg)
|
|
57
|
+
|
|
58
|
+
return positional, flags
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_command_module(command):
|
|
62
|
+
"""Import and return the command module for the given command name."""
|
|
63
|
+
# Map CLI command names to Python module names
|
|
64
|
+
module_map = {
|
|
65
|
+
"release": "release",
|
|
66
|
+
"status": "status",
|
|
67
|
+
"scaffold": "init_cmd",
|
|
68
|
+
"check-name": "check_name",
|
|
69
|
+
}
|
|
70
|
+
module_name = module_map.get(command)
|
|
71
|
+
if not module_name:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Import the command module
|
|
75
|
+
from importlib import import_module
|
|
76
|
+
return import_module(f".commands.{module_name}", package="rlsbl")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
positional, flags = parse_args(sys.argv)
|
|
81
|
+
|
|
82
|
+
# Top-level flags
|
|
83
|
+
if flags.get("help") or flags.get("h"):
|
|
84
|
+
print(HELP)
|
|
85
|
+
sys.exit(0)
|
|
86
|
+
|
|
87
|
+
if flags.get("version") or flags.get("v"):
|
|
88
|
+
print(__version__)
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
|
|
91
|
+
first = positional[0] if positional else None
|
|
92
|
+
|
|
93
|
+
# Resolve command aliases (e.g. "init" -> "scaffold")
|
|
94
|
+
if first in COMMAND_ALIASES:
|
|
95
|
+
first = COMMAND_ALIASES[first]
|
|
96
|
+
|
|
97
|
+
if not first:
|
|
98
|
+
print("Error: missing command.\n", file=sys.stderr)
|
|
99
|
+
print(HELP, file=sys.stderr)
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
registry = None
|
|
103
|
+
command = None
|
|
104
|
+
args = []
|
|
105
|
+
|
|
106
|
+
if first in COMMANDS:
|
|
107
|
+
# Top-level: rlsbl <command> ... -- auto-detect registry
|
|
108
|
+
command = first
|
|
109
|
+
args = positional[1:]
|
|
110
|
+
elif first in REGISTRIES:
|
|
111
|
+
# Registry-prefixed: rlsbl <registry> <command> ...
|
|
112
|
+
registry = first
|
|
113
|
+
command = positional[1] if len(positional) > 1 else None
|
|
114
|
+
if command and command in COMMAND_ALIASES:
|
|
115
|
+
command = COMMAND_ALIASES[command]
|
|
116
|
+
|
|
117
|
+
if not command:
|
|
118
|
+
print(f'Error: missing command for registry "{registry}".\n', file=sys.stderr)
|
|
119
|
+
print(HELP, file=sys.stderr)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
if command not in COMMANDS:
|
|
123
|
+
print(
|
|
124
|
+
f'Error: unknown command "{command}". Valid commands: {", ".join(COMMANDS)}\n',
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
print(HELP, file=sys.stderr)
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
|
|
130
|
+
args = positional[2:]
|
|
131
|
+
else:
|
|
132
|
+
print(f'Error: unknown command or registry "{first}".\n', file=sys.stderr)
|
|
133
|
+
print(HELP, file=sys.stderr)
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
handler = _get_command_module(command)
|
|
138
|
+
if handler is None:
|
|
139
|
+
print(f'Error: command "{command}" is not yet implemented.', file=sys.stderr)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
if registry:
|
|
143
|
+
# Explicit registry -- single invocation
|
|
144
|
+
handler.run_cmd(registry, args, flags)
|
|
145
|
+
elif command == "check-name":
|
|
146
|
+
# Top-level check-name: check ALL registries
|
|
147
|
+
regs = ["npm", "pypi"]
|
|
148
|
+
for i, r in enumerate(regs):
|
|
149
|
+
handler.run_cmd(r, args, flags)
|
|
150
|
+
if i < len(regs) - 1:
|
|
151
|
+
print("")
|
|
152
|
+
elif command == "scaffold":
|
|
153
|
+
# Top-level scaffold: scaffold for each detected registry
|
|
154
|
+
regs = detect_registries()
|
|
155
|
+
if not regs:
|
|
156
|
+
print("Error: no package.json or pyproject.toml found.", file=sys.stderr)
|
|
157
|
+
sys.exit(1)
|
|
158
|
+
if len(regs) > 1:
|
|
159
|
+
# Multi-registry: only scaffold for the primary registry
|
|
160
|
+
# (CI/publish workflows conflict when both registries target the same paths)
|
|
161
|
+
print(f"Multiple registries detected: {', '.join(regs)}")
|
|
162
|
+
print(f"Scaffolding for primary registry: {regs[0]}")
|
|
163
|
+
print("For dual-registry projects, manually configure workflows with both jobs.")
|
|
164
|
+
handler.run_cmd(regs[0], args, flags)
|
|
165
|
+
else:
|
|
166
|
+
# Top-level release/status: use primary detected registry
|
|
167
|
+
regs = detect_registries()
|
|
168
|
+
if not regs:
|
|
169
|
+
print("Error: no package.json or pyproject.toml found.", file=sys.stderr)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
handler.run_cmd(regs[0], args, flags)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
174
|
+
sys.exit(1)
|
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Check-name command: check package name availability on npm or PyPI."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.request
|
|
7
|
+
import urllib.error
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_npm(name):
|
|
11
|
+
"""Normalize an npm package name for similarity comparison.
|
|
12
|
+
|
|
13
|
+
Strips hyphens, underscores, dots, and lowercases.
|
|
14
|
+
"""
|
|
15
|
+
return re.sub(r"[-_.]", "", name.lower())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_pypi(name):
|
|
19
|
+
"""Normalize a PyPI package name per PEP 503.
|
|
20
|
+
|
|
21
|
+
Lowercases and replaces runs of [-_.] with a single hyphen.
|
|
22
|
+
"""
|
|
23
|
+
return re.sub(r"[-_.]+", "-", name.lower())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_npm_availability(name):
|
|
27
|
+
"""Check if an npm package name is available.
|
|
28
|
+
|
|
29
|
+
Returns {"status": "available"|"taken"|"error", "message"?: str}.
|
|
30
|
+
Distinguishes 404 (truly available) from network/other errors.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
subprocess.run(
|
|
34
|
+
["npm", "view", name, "name"],
|
|
35
|
+
capture_output=True, text=True, check=True,
|
|
36
|
+
)
|
|
37
|
+
return {"status": "taken"}
|
|
38
|
+
except subprocess.CalledProcessError as e:
|
|
39
|
+
stderr = e.stderr or ""
|
|
40
|
+
if "E404" in stderr or "404" in stderr:
|
|
41
|
+
return {"status": "available"}
|
|
42
|
+
return {"status": "error", "message": stderr.strip() or "Unknown error checking npm"}
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
return {"status": "error", "message": "npm CLI not found"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_npm_variants(name):
|
|
48
|
+
"""Generate common npm name variants for similarity checking."""
|
|
49
|
+
variants = set()
|
|
50
|
+
lower = name.lower()
|
|
51
|
+
variants.add(lower)
|
|
52
|
+
variants.add(lower.replace("_", "-"))
|
|
53
|
+
variants.add(lower.replace("-", "_"))
|
|
54
|
+
variants.add(re.sub(r"[-_]", "", lower))
|
|
55
|
+
variants.add(re.sub(r"[-_]", ".", lower))
|
|
56
|
+
|
|
57
|
+
# Remove the original name itself from the set
|
|
58
|
+
variants.discard(name)
|
|
59
|
+
|
|
60
|
+
return list(variants)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_pypi_availability(name):
|
|
64
|
+
"""Check if a PyPI package name is available.
|
|
65
|
+
|
|
66
|
+
Returns {"status": "available"|"taken"|"error", "message"?: str}.
|
|
67
|
+
Distinguishes 404 (truly available) from network/other errors.
|
|
68
|
+
"""
|
|
69
|
+
url = f"https://pypi.org/pypi/{name}/json"
|
|
70
|
+
try:
|
|
71
|
+
req = urllib.request.Request(url, method="GET")
|
|
72
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
73
|
+
if resp.status == 200:
|
|
74
|
+
return {"status": "taken"}
|
|
75
|
+
return {"status": "error", "message": f"Unexpected status {resp.status}"}
|
|
76
|
+
except urllib.error.HTTPError as e:
|
|
77
|
+
if e.code == 404:
|
|
78
|
+
return {"status": "available"}
|
|
79
|
+
return {"status": "error", "message": f"Unexpected status {e.code}"}
|
|
80
|
+
except Exception as e:
|
|
81
|
+
return {"status": "error", "message": str(e) or "Network error"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_pypi_variants(name):
|
|
85
|
+
"""Generate common PyPI name variants for similarity checking."""
|
|
86
|
+
normalized = normalize_pypi(name)
|
|
87
|
+
lower = name.lower()
|
|
88
|
+
variants = set()
|
|
89
|
+
variants.add(normalized)
|
|
90
|
+
variants.add(re.sub(r"[-_.]+", "_", lower))
|
|
91
|
+
variants.add(re.sub(r"[-_.]+", "-", lower))
|
|
92
|
+
variants.add(re.sub(r"[-_.]+", "", lower))
|
|
93
|
+
|
|
94
|
+
# Remove the original name itself
|
|
95
|
+
variants.discard(name)
|
|
96
|
+
|
|
97
|
+
return list(variants)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_name_npm(name):
|
|
101
|
+
"""Check npm availability and report similar names."""
|
|
102
|
+
print(f'Checking npm for "{name}"...')
|
|
103
|
+
|
|
104
|
+
result = check_npm_availability(name)
|
|
105
|
+
if result["status"] == "error":
|
|
106
|
+
print(f"Error checking npm: {result['message']}", file=sys.stderr)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
available = result["status"] == "available"
|
|
109
|
+
if available:
|
|
110
|
+
print(f'"{name}" is available on npm.')
|
|
111
|
+
else:
|
|
112
|
+
print(f'"{name}" is taken on npm.')
|
|
113
|
+
|
|
114
|
+
# Check variants for similarity; skip variants that error
|
|
115
|
+
variants = get_npm_variants(name)
|
|
116
|
+
similar = []
|
|
117
|
+
for variant in variants:
|
|
118
|
+
var_result = check_npm_availability(variant)
|
|
119
|
+
if var_result["status"] == "taken":
|
|
120
|
+
similar.append(variant)
|
|
121
|
+
|
|
122
|
+
if similar:
|
|
123
|
+
print("\nSimilar names already taken:")
|
|
124
|
+
for s in similar:
|
|
125
|
+
print(f" {s}")
|
|
126
|
+
if available:
|
|
127
|
+
print(
|
|
128
|
+
"\nYour name is available but has similar existing packages. "
|
|
129
|
+
"Consider if this could cause confusion."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _check_name_pypi(name):
|
|
134
|
+
"""Check PyPI availability and report similar names."""
|
|
135
|
+
print(f'Checking PyPI for "{name}"...')
|
|
136
|
+
|
|
137
|
+
result = check_pypi_availability(name)
|
|
138
|
+
if result["status"] == "error":
|
|
139
|
+
print(f"Error checking PyPI: {result['message']}", file=sys.stderr)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
available = result["status"] == "available"
|
|
142
|
+
if available:
|
|
143
|
+
print(f'"{name}" is available on PyPI.')
|
|
144
|
+
else:
|
|
145
|
+
print(f'"{name}" is taken on PyPI.')
|
|
146
|
+
|
|
147
|
+
# Check variants for similarity (PEP 503 normalization); skip variants that error
|
|
148
|
+
variants = get_pypi_variants(name)
|
|
149
|
+
similar = []
|
|
150
|
+
for variant in variants:
|
|
151
|
+
if variant == name:
|
|
152
|
+
continue
|
|
153
|
+
var_result = check_pypi_availability(variant)
|
|
154
|
+
if var_result["status"] == "taken":
|
|
155
|
+
similar.append(variant)
|
|
156
|
+
|
|
157
|
+
if similar:
|
|
158
|
+
print("\nSimilar names already taken:")
|
|
159
|
+
for s in similar:
|
|
160
|
+
print(f" {s}")
|
|
161
|
+
if available:
|
|
162
|
+
print(
|
|
163
|
+
"\nYour name is available but has similar existing packages. "
|
|
164
|
+
"Consider if this could cause confusion."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def run_cmd(registry, args, flags):
|
|
169
|
+
"""Check-name command handler.
|
|
170
|
+
|
|
171
|
+
Checks package name availability on npm or PyPI, and warns about similar names.
|
|
172
|
+
"""
|
|
173
|
+
name = args[0] if args else None
|
|
174
|
+
if not name:
|
|
175
|
+
print(
|
|
176
|
+
"Error: missing package name. Usage: rlsbl check-name <name>",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
181
|
+
if registry == "npm":
|
|
182
|
+
_check_name_npm(name)
|
|
183
|
+
else:
|
|
184
|
+
_check_name_pypi(name)
|