rlsbl 0.8.2 → 0.9.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.
Files changed (37) hide show
  1. package/package.json +3 -5
  2. package/rlsbl/__init__.py +0 -247
  3. package/rlsbl/__main__.py +0 -4
  4. package/rlsbl/commands/__init__.py +0 -0
  5. package/rlsbl/commands/check.py +0 -229
  6. package/rlsbl/commands/config.py +0 -67
  7. package/rlsbl/commands/discover.py +0 -198
  8. package/rlsbl/commands/init_cmd.py +0 -518
  9. package/rlsbl/commands/pre_push_check.py +0 -46
  10. package/rlsbl/commands/record_gif.py +0 -92
  11. package/rlsbl/commands/release.py +0 -287
  12. package/rlsbl/commands/status.py +0 -76
  13. package/rlsbl/commands/undo.py +0 -74
  14. package/rlsbl/commands/watch.py +0 -122
  15. package/rlsbl/config.py +0 -57
  16. package/rlsbl/registries/__init__.py +0 -5
  17. package/rlsbl/registries/go.py +0 -123
  18. package/rlsbl/registries/npm.py +0 -119
  19. package/rlsbl/registries/pypi.py +0 -171
  20. package/rlsbl/tagging.py +0 -207
  21. package/rlsbl/templates/go/VERSION.tpl +0 -1
  22. package/rlsbl/templates/go/ci.yml.tpl +0 -21
  23. package/rlsbl/templates/go/goreleaser.yml.tpl +0 -25
  24. package/rlsbl/templates/go/publish.yml.tpl +0 -25
  25. package/rlsbl/templates/merged/publish.yml.tpl +0 -30
  26. package/rlsbl/templates/npm/ci.yml.tpl +0 -22
  27. package/rlsbl/templates/npm/publish.yml.tpl +0 -22
  28. package/rlsbl/templates/pypi/ci.yml.tpl +0 -20
  29. package/rlsbl/templates/pypi/publish.yml.tpl +0 -18
  30. package/rlsbl/templates/shared/CHANGELOG.md.tpl +0 -5
  31. package/rlsbl/templates/shared/CLAUDE.md.tpl +0 -20
  32. package/rlsbl/templates/shared/LICENSE.tpl +0 -21
  33. package/rlsbl/templates/shared/claude-settings.json.tpl +0 -3
  34. package/rlsbl/templates/shared/gitignore.tpl +0 -14
  35. package/rlsbl/templates/shared/hooks/post-release.sh.tpl +0 -8
  36. package/rlsbl/templates/shared/hooks/pre-release.sh.tpl +0 -31
  37. package/rlsbl/utils.py +0 -131
package/package.json CHANGED
@@ -1,15 +1,13 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.8.2",
4
- "description": "Release orchestration and project scaffolding for npm and PyPI",
3
+ "version": "0.9.0",
4
+ "description": "Release orchestration and project scaffolding for npm, PyPI, and Go",
5
5
  "license": "MIT",
6
6
  "bin": {
7
7
  "rlsbl": "bin/cli.js"
8
8
  },
9
9
  "files": [
10
- "bin/",
11
- "rlsbl/",
12
- "templates/"
10
+ "bin/"
13
11
  ],
14
12
  "repository": {
15
13
  "type": "git",
package/rlsbl/__init__.py DELETED
@@ -1,247 +0,0 @@
1
- """rlsbl: Release orchestration and project scaffolding for npm, PyPI, and Go."""
2
-
3
- import os
4
- import sys
5
-
6
-
7
- def _detect_version():
8
- """Detect package version, preferring pyproject.toml over installed metadata.
9
-
10
- Order: pyproject.toml in the source tree (accurate during editable installs)
11
- -> importlib.metadata (works for regular installs) -> "unknown".
12
- """
13
- # Try reading version from pyproject.toml next to the package source
14
- try:
15
- pyproject_path = os.path.realpath(
16
- os.path.join(os.path.dirname(__file__), "..", "pyproject.toml")
17
- )
18
- if os.path.isfile(pyproject_path):
19
- try:
20
- import tomllib
21
- except ModuleNotFoundError:
22
- import tomli as tomllib # type: ignore[no-redef]
23
- with open(pyproject_path, "rb") as f:
24
- data = tomllib.load(f)
25
- return data["project"]["version"]
26
- except Exception:
27
- pass
28
-
29
- # Fall back to installed dist-info metadata
30
- try:
31
- from importlib.metadata import version as _get_version
32
- return _get_version("rlsbl")
33
- except Exception:
34
- pass
35
-
36
- return "unknown"
37
-
38
-
39
- __version__ = _detect_version()
40
-
41
- REGISTRIES = ("npm", "pypi", "go")
42
- COMMANDS = ("release", "status", "scaffold", "check", "config", "undo", "discover", "watch",
43
- "pre-push-check", "record-gif")
44
- COMMAND_ALIASES = {"init": "scaffold"}
45
-
46
- HELP = f"""\
47
- rlsbl v{__version__} -- Release orchestration and project scaffolding for npm, PyPI, and Go
48
-
49
- Usage:
50
- rlsbl release [patch|minor|major] [--dry-run] [--yes] [--quiet] Orchestrate a release
51
- rlsbl status Show project status
52
- rlsbl scaffold [--force] [--update] Scaffold release infrastructure
53
- rlsbl check <name> Check name availability
54
- rlsbl config Show project configuration
55
- rlsbl undo [--yes] Revert the last release
56
- rlsbl discover [--mine] List rlsbl ecosystem projects
57
- rlsbl watch [<commit-sha>] Watch CI runs for a commit
58
- rlsbl pre-push-check Verify CHANGELOG entry for current version
59
- rlsbl record-gif [--width N] [--height N] [--font-size N] [--duration N]
60
- Record a demo GIF with vhs
61
-
62
- Options:
63
- --registry <npm|pypi|go> Target a specific registry (auto-detected if omitted)
64
- --no-tag Disable ecosystem tagging for this invocation
65
- --help, -h Show this help
66
- --version, -v Show version"""
67
-
68
-
69
- def detect_registries():
70
- """Detect all registries that have a project file in the current directory.
71
-
72
- Returns a list, e.g. ["npm"], ["pypi"], or ["npm", "pypi"].
73
- """
74
- found = []
75
- if os.path.exists("package.json"):
76
- found.append("npm")
77
- if os.path.exists("pyproject.toml"):
78
- found.append("pypi")
79
- if os.path.exists("go.mod"):
80
- found.append("go")
81
- return found
82
-
83
-
84
- def parse_args(argv):
85
- """Parse sys.argv into positional args and flags.
86
-
87
- Flags listed in VALUE_FLAGS consume the next token as their value
88
- (e.g. --registry npm). All other --flags are boolean.
89
- """
90
- VALUE_FLAGS = ("registry", "width", "height", "font-size", "duration")
91
- raw = argv[1:]
92
- positional = []
93
- flags = {}
94
- i = 0
95
- while i < len(raw):
96
- arg = raw[i]
97
- if arg.startswith("--"):
98
- key = arg[2:]
99
- if "=" in key:
100
- k, v = key.split("=", 1)
101
- flags[k] = v
102
- elif key in VALUE_FLAGS and i + 1 < len(raw) and not raw[i + 1].startswith("-"):
103
- flags[key] = raw[i + 1]
104
- i += 1
105
- else:
106
- flags[key] = True
107
- elif arg.startswith("-") and len(arg) == 2:
108
- flags[arg[1:]] = True
109
- else:
110
- positional.append(arg)
111
- i += 1
112
- return positional, flags
113
-
114
-
115
- def _get_command_module(command):
116
- """Import and return the command module for the given command name."""
117
- # Map CLI command names to Python module names
118
- module_map = {
119
- "release": "release",
120
- "status": "status",
121
- "scaffold": "init_cmd",
122
- "check": "check",
123
- "config": "config",
124
- "undo": "undo",
125
- "discover": "discover",
126
- "watch": "watch",
127
- "pre-push-check": "pre_push_check",
128
- "record-gif": "record_gif",
129
- }
130
- module_name = module_map.get(command)
131
- if not module_name:
132
- return None
133
-
134
- # Import the command module
135
- from importlib import import_module
136
- return import_module(f".commands.{module_name}", package="rlsbl")
137
-
138
-
139
- def main():
140
- positional, flags = parse_args(sys.argv)
141
-
142
- # Top-level flags
143
- if flags.get("help") or flags.get("h"):
144
- print(HELP)
145
- sys.exit(0)
146
-
147
- if flags.get("version") or flags.get("v"):
148
- print(__version__)
149
- sys.exit(0)
150
-
151
- command = positional[0] if positional else None
152
-
153
- # Resolve command aliases (e.g. "init" -> "scaffold")
154
- if command in COMMAND_ALIASES:
155
- command = COMMAND_ALIASES[command]
156
-
157
- if not command:
158
- print("Error: missing command.\n", file=sys.stderr)
159
- print(HELP, file=sys.stderr)
160
- sys.exit(1)
161
-
162
- if command not in COMMANDS:
163
- print(f'Error: unknown command "{command}".\n', file=sys.stderr)
164
- print(HELP, file=sys.stderr)
165
- sys.exit(1)
166
-
167
- args = positional[1:]
168
- registry = flags.get("registry")
169
-
170
- # --registry was the last arg with no value following it
171
- if registry is True:
172
- print("Error: --registry requires a value (npm, pypi, or go).", file=sys.stderr)
173
- sys.exit(1)
174
-
175
- # Validate --registry if provided
176
- if registry and registry not in REGISTRIES:
177
- print(
178
- f"Error: unknown registry '{registry}'. Valid: {', '.join(REGISTRIES)}",
179
- file=sys.stderr,
180
- )
181
- sys.exit(1)
182
-
183
- try:
184
- handler = _get_command_module(command)
185
- if handler is None:
186
- print(f'Error: command "{command}" is not yet implemented.', file=sys.stderr)
187
- sys.exit(1)
188
-
189
- if command == "check":
190
- # check: if registry given, check that one; otherwise check npm and pypi.
191
- # Go is excluded from the default set because Go modules use repository
192
- # paths (e.g. github.com/user/repo), not a flat claimable namespace, so
193
- # "available" would be misleading. Pass --registry go explicitly to check.
194
- if registry:
195
- handler.run_cmd(registry, args, flags)
196
- else:
197
- default_registries = ["npm", "pypi"]
198
- for i, r in enumerate(default_registries):
199
- handler.run_cmd(r, args, flags)
200
- if i < len(default_registries) - 1:
201
- print("")
202
- elif command == "scaffold":
203
- if registry:
204
- handler.run_cmd(registry, args, flags)
205
- else:
206
- regs = detect_registries()
207
- if not regs:
208
- print("Error: no package.json, pyproject.toml, or go.mod found.", file=sys.stderr)
209
- sys.exit(1)
210
- if len(regs) > 1:
211
- handler.run_cmd_multi(regs, args, flags)
212
- else:
213
- handler.run_cmd(regs[0], args, flags)
214
- elif command == "config":
215
- # config: auto-detect, pass first registry or fallback
216
- regs = detect_registries()
217
- handler.run_cmd(registry or (regs[0] if regs else "npm"), args, flags)
218
- elif command == "undo":
219
- # undo: auto-detect like release
220
- if not registry:
221
- regs = detect_registries()
222
- if not regs:
223
- print("Error: no package.json, pyproject.toml, or go.mod found.", file=sys.stderr)
224
- sys.exit(1)
225
- registry = regs[0]
226
- handler.run_cmd(registry, args, flags)
227
- elif command == "discover":
228
- # discover: global query, no registry needed
229
- handler.run_cmd(registry, args, flags)
230
- elif command == "watch":
231
- # watch: monitors CI runs, no registry needed
232
- handler.run_cmd(registry, args, flags)
233
- elif command in ("pre-push-check", "record-gif"):
234
- # Standalone commands, no registry needed
235
- handler.run_cmd(registry, args, flags)
236
- else:
237
- # release, status: use explicit registry or auto-detect primary
238
- if not registry:
239
- regs = detect_registries()
240
- if not regs:
241
- print("Error: no package.json, pyproject.toml, or go.mod found.", file=sys.stderr)
242
- sys.exit(1)
243
- registry = regs[0]
244
- handler.run_cmd(registry, args, flags)
245
- except Exception as e:
246
- print(f"Error: {e}", file=sys.stderr)
247
- sys.exit(1)
package/rlsbl/__main__.py DELETED
@@ -1,4 +0,0 @@
1
- """Allow running as python -m rlsbl."""
2
- from . import main
3
-
4
- main()
File without changes
@@ -1,229 +0,0 @@
1
- """Check command: check package name availability on npm, PyPI, or Go (pkg.go.dev)."""
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 check_go_availability(name):
169
- """Check if a Go module path exists on pkg.go.dev.
170
-
171
- Returns {"status": "not_found"|"exists"|"error", "message"?: str, "note"?: str}.
172
-
173
- Go modules use repository paths (e.g. github.com/user/repo), not a flat
174
- claimable namespace, so we report "not found" / "exists" rather than the
175
- "available" / "taken" language used for npm and PyPI.
176
- """
177
- url = f"https://pkg.go.dev/{name}"
178
- try:
179
- req = urllib.request.Request(url, method="GET")
180
- with urllib.request.urlopen(req, timeout=5) as resp:
181
- if resp.status == 200:
182
- return {"status": "exists"}
183
- return {"status": "error", "message": f"Unexpected status {resp.status}"}
184
- except urllib.error.HTTPError as e:
185
- if e.code == 404:
186
- return {
187
- "status": "not_found",
188
- "note": "Go modules use repository paths, not a central registry.",
189
- }
190
- return {"status": "error", "message": f"Unexpected status {e.code}"}
191
- except Exception as e:
192
- return {"status": "error", "message": str(e) or "Network error"}
193
-
194
-
195
- def _check_name_go(name):
196
- """Check Go module path on pkg.go.dev."""
197
- print(f'Checking pkg.go.dev for "{name}"...')
198
-
199
- result = check_go_availability(name)
200
- if result["status"] == "error":
201
- print(f"Error checking pkg.go.dev: {result['message']}", file=sys.stderr)
202
- sys.exit(1)
203
- if result["status"] == "not_found":
204
- print(f'"{name}" not found on pkg.go.dev.')
205
- else:
206
- print(f'"{name}" exists on pkg.go.dev.')
207
- if result.get("note"):
208
- print(f" Note: {result['note']}")
209
-
210
-
211
- def run_cmd(registry, args, flags):
212
- """Check command handler.
213
-
214
- Checks package name availability on npm, PyPI, or Go, and warns about similar names.
215
- """
216
- name = args[0] if args else None
217
- if not name:
218
- print(
219
- "Error: missing package name. Usage: rlsbl check <name>",
220
- file=sys.stderr,
221
- )
222
- sys.exit(1)
223
-
224
- if registry == "npm":
225
- _check_name_npm(name)
226
- elif registry == "pypi":
227
- _check_name_pypi(name)
228
- elif registry == "go":
229
- _check_name_go(name)
@@ -1,67 +0,0 @@
1
- """Config command: show resolved project configuration."""
2
-
3
- import os
4
- from ..config import _project_config, USER_CONFIG, read_json_config, should_tag
5
- from ..registries import REGISTRIES
6
-
7
-
8
- def run_cmd(registry, args, flags):
9
- print("Detected registries:")
10
- for name, reg in REGISTRIES.items():
11
- if reg.check_project_exists("."):
12
- version = reg.read_version(".")
13
- vfile = reg.get_version_file() or "git tag"
14
- print(f" {name}: {vfile} (v{version})")
15
- else:
16
- print(f" {name}: not found")
17
-
18
- print("\nScaffolding:")
19
- rlsbl_dir = os.path.join(".", ".rlsbl")
20
- if os.path.isdir(rlsbl_dir):
21
- version_file = os.path.join(rlsbl_dir, "version")
22
- if os.path.exists(version_file):
23
- with open(version_file) as f:
24
- scaffold_ver = f.read().strip()
25
- print(f" Version marker: {scaffold_ver}")
26
- hashes_file = os.path.join(rlsbl_dir, "hashes.json")
27
- if os.path.exists(hashes_file):
28
- import json
29
- with open(hashes_file) as f:
30
- hashes = json.load(f)
31
- print(f" Tracked files: {len(hashes)}")
32
- for path in sorted(hashes):
33
- print(f" {path}")
34
- else:
35
- print(" Not scaffolded (run 'rlsbl scaffold')")
36
-
37
- print("\nWorkflows:")
38
- for wf in ["ci.yml", "publish.yml", "workflow.yml"]:
39
- path = os.path.join(".github", "workflows", wf)
40
- print(f" {wf}: {'yes' if os.path.exists(path) else 'no'}")
41
-
42
- print("\nHooks:")
43
- pre_release = os.path.join(".rlsbl", "hooks", "pre-release.sh")
44
- print(f" pre-release.sh: {'yes' if os.path.exists(pre_release) else 'no'}")
45
- post_release = os.path.join(".rlsbl", "hooks", "post-release.sh")
46
- print(f" post-release.sh: {'yes' if os.path.exists(post_release) else 'no'}")
47
- pre_push = os.path.join(".git", "hooks", "pre-push")
48
- print(f" pre-push hook: {'installed' if os.path.exists(pre_push) else 'not installed'}")
49
-
50
- print("\nEcosystem tagging:")
51
- enabled = should_tag(flags)
52
- # Determine why it's enabled/disabled
53
- project_cfg = read_json_config(_project_config())
54
- user_cfg = read_json_config(USER_CONFIG)
55
- if flags.get("no-tag"):
56
- source = "CLI flag"
57
- elif "tag" in project_cfg:
58
- source = f"project config ({_project_config()})"
59
- elif "tag" in user_cfg:
60
- source = f"user config ({USER_CONFIG})"
61
- else:
62
- source = "default"
63
- print(f" Status: {'enabled' if enabled else 'disabled'} ({source})")
64
-
65
- print("\nFiles:")
66
- for f in ["CHANGELOG.md", "LICENSE", ".gitignore", "CLAUDE.md"]:
67
- print(f" {f}: {'yes' if os.path.exists(f) else 'no'}")