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 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.1",
3
+ "version": "0.1.0",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
- "author": "smm-h",
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: releasable."""
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)
@@ -0,0 +1,4 @@
1
+ """Allow running as python -m rlsbl."""
2
+ from . import main
3
+
4
+ main()
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)