rlsbl 0.8.3 → 0.9.1

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 (38) hide show
  1. package/README.md +117 -112
  2. package/package.json +3 -5
  3. package/rlsbl/__init__.py +0 -247
  4. package/rlsbl/__main__.py +0 -4
  5. package/rlsbl/commands/__init__.py +0 -0
  6. package/rlsbl/commands/check.py +0 -229
  7. package/rlsbl/commands/config.py +0 -67
  8. package/rlsbl/commands/discover.py +0 -198
  9. package/rlsbl/commands/init_cmd.py +0 -518
  10. package/rlsbl/commands/pre_push_check.py +0 -46
  11. package/rlsbl/commands/record_gif.py +0 -92
  12. package/rlsbl/commands/release.py +0 -287
  13. package/rlsbl/commands/status.py +0 -76
  14. package/rlsbl/commands/undo.py +0 -74
  15. package/rlsbl/commands/watch.py +0 -125
  16. package/rlsbl/config.py +0 -57
  17. package/rlsbl/registries/__init__.py +0 -5
  18. package/rlsbl/registries/go.py +0 -123
  19. package/rlsbl/registries/npm.py +0 -119
  20. package/rlsbl/registries/pypi.py +0 -171
  21. package/rlsbl/tagging.py +0 -207
  22. package/rlsbl/templates/go/VERSION.tpl +0 -1
  23. package/rlsbl/templates/go/ci.yml.tpl +0 -18
  24. package/rlsbl/templates/go/goreleaser.yml.tpl +0 -25
  25. package/rlsbl/templates/go/publish.yml.tpl +0 -25
  26. package/rlsbl/templates/merged/publish.yml.tpl +0 -30
  27. package/rlsbl/templates/npm/ci.yml.tpl +0 -22
  28. package/rlsbl/templates/npm/publish.yml.tpl +0 -22
  29. package/rlsbl/templates/pypi/ci.yml.tpl +0 -20
  30. package/rlsbl/templates/pypi/publish.yml.tpl +0 -18
  31. package/rlsbl/templates/shared/CHANGELOG.md.tpl +0 -5
  32. package/rlsbl/templates/shared/CLAUDE.md.tpl +0 -20
  33. package/rlsbl/templates/shared/LICENSE.tpl +0 -21
  34. package/rlsbl/templates/shared/claude-settings.json.tpl +0 -3
  35. package/rlsbl/templates/shared/gitignore.tpl +0 -14
  36. package/rlsbl/templates/shared/hooks/post-release.sh.tpl +0 -8
  37. package/rlsbl/templates/shared/hooks/pre-release.sh.tpl +0 -31
  38. package/rlsbl/utils.py +0 -131
@@ -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'}")
@@ -1,198 +0,0 @@
1
- """Discover command: list projects in the rlsbl ecosystem."""
2
-
3
- import json
4
- import os
5
- import subprocess
6
- import sys
7
- import urllib.error
8
- import urllib.request
9
-
10
-
11
- SEARCH_URL = "https://api.github.com/search/repositories?q=topic:rlsbl&sort=updated&per_page=100"
12
- MAX_RESULTS = 1000
13
-
14
-
15
- def _get_github_token():
16
- """Get a GitHub token from GITHUB_TOKEN env or `gh auth token`."""
17
- token = os.environ.get("GITHUB_TOKEN")
18
- if token:
19
- return token
20
- try:
21
- result = subprocess.run(
22
- ["gh", "auth", "token"],
23
- capture_output=True, text=True, check=True, timeout=10,
24
- )
25
- return result.stdout.strip() or None
26
- except Exception:
27
- return None
28
-
29
-
30
- def _make_request(url, token):
31
- """Make a GET request to the GitHub API, return parsed JSON and response headers."""
32
- req = urllib.request.Request(url, method="GET")
33
- req.add_header("Accept", "application/vnd.github+json")
34
- req.add_header("User-Agent", "rlsbl-cli")
35
- if token:
36
- req.add_header("Authorization", f"token {token}")
37
- with urllib.request.urlopen(req, timeout=15) as resp:
38
- data = json.loads(resp.read().decode("utf-8"))
39
- headers = dict(resp.headers)
40
- return data, headers
41
-
42
-
43
- def _parse_next_link(headers):
44
- """Extract the 'next' URL from the Link header, or None."""
45
- link = headers.get("Link") or headers.get("link")
46
- if not link:
47
- return None
48
- for part in link.split(","):
49
- if 'rel="next"' in part:
50
- start = part.index("<") + 1
51
- end = part.index(">")
52
- url = part[start:end]
53
- if not url.startswith("https://api.github.com/"):
54
- return None
55
- return url
56
- return None
57
-
58
-
59
- def _relative_time(iso_timestamp):
60
- """Convert an ISO 8601 timestamp to a relative time string like '2d ago'."""
61
- from datetime import datetime, timezone
62
-
63
- if not iso_timestamp:
64
- return ""
65
-
66
- # Parse ISO timestamp (GitHub uses Z suffix)
67
- ts = iso_timestamp.replace("Z", "+00:00")
68
- dt = datetime.fromisoformat(ts)
69
- now = datetime.now(timezone.utc)
70
- delta = now - dt
71
-
72
- seconds = int(delta.total_seconds())
73
- if seconds < 60:
74
- return "just now"
75
- minutes = seconds // 60
76
- if minutes < 60:
77
- return f"{minutes}m ago"
78
- hours = minutes // 60
79
- if hours < 24:
80
- return f"{hours}h ago"
81
- days = hours // 24
82
- if days < 7:
83
- return f"{days}d ago"
84
- weeks = days // 7
85
- if weeks < 5:
86
- return f"{weeks}w ago"
87
- months = days // 30
88
- if months < 12:
89
- return f"{months}mo ago"
90
- years = days // 365
91
- return f"{years}y ago"
92
-
93
-
94
- def _get_authenticated_user(token):
95
- """Get the authenticated user's login name."""
96
- if not token:
97
- return None
98
- try:
99
- req = urllib.request.Request("https://api.github.com/user", method="GET")
100
- req.add_header("Accept", "application/vnd.github+json")
101
- req.add_header("User-Agent", "rlsbl-cli")
102
- req.add_header("Authorization", f"token {token}")
103
- with urllib.request.urlopen(req, timeout=10) as resp:
104
- data = json.loads(resp.read().decode("utf-8"))
105
- return data.get("login")
106
- except Exception:
107
- return None
108
-
109
-
110
- def _fetch_all_repos(token):
111
- """Fetch all repos with the rlsbl topic, handling pagination."""
112
- repos = []
113
- url = SEARCH_URL
114
-
115
- while url and len(repos) < MAX_RESULTS:
116
- data, headers = _make_request(url, token)
117
- items = data.get("items", [])
118
- repos.extend(items)
119
- url = _parse_next_link(headers)
120
-
121
- return repos
122
-
123
-
124
- def run_cmd(registry, args, flags):
125
- """Discover command: list projects in the rlsbl ecosystem."""
126
- token = _get_github_token()
127
- mine_only = flags.get("mine", False)
128
-
129
- if mine_only and not token:
130
- print("Error: --mine requires authentication (set GITHUB_TOKEN or install gh CLI).", file=sys.stderr)
131
- sys.exit(1)
132
-
133
- # Fetch repos
134
- try:
135
- repos = _fetch_all_repos(token)
136
- except urllib.error.HTTPError as e:
137
- print(f"Error: GitHub API returned {e.code}: {e.reason}", file=sys.stderr)
138
- if e.code == 403:
139
- print("Hint: run 'gh auth login' to increase API rate limits (60/hr unauthenticated → 5000/hr).", file=sys.stderr)
140
- sys.exit(1)
141
- except urllib.error.URLError as e:
142
- print(f"Error: could not reach GitHub API: {e.reason}", file=sys.stderr)
143
- sys.exit(1)
144
- except Exception as e:
145
- print(f"Error: {e}", file=sys.stderr)
146
- sys.exit(1)
147
-
148
- # Filter to --mine if requested
149
- if mine_only:
150
- username = _get_authenticated_user(token)
151
- if not username:
152
- print("Error: could not determine authenticated user.", file=sys.stderr)
153
- sys.exit(1)
154
- repos = [r for r in repos if r.get("owner", {}).get("login") == username]
155
-
156
- if not repos:
157
- if mine_only:
158
- print("No rlsbl-tagged repositories found for your account.")
159
- else:
160
- print("No rlsbl-tagged repositories found.")
161
- return
162
-
163
- # Build table rows
164
- rows = []
165
- for repo in repos:
166
- full_name = repo.get("full_name", "")
167
- description = repo.get("description") or ""
168
- updated = _relative_time(repo.get("updated_at", ""))
169
- rows.append((full_name, description, updated))
170
-
171
- # Calculate column widths
172
- name_width = max(len(r[0]) for r in rows)
173
- desc_width = max(len(r[1]) for r in rows)
174
- time_width = max(len(r[2]) for r in rows)
175
-
176
- # Cap description width to keep output readable
177
- max_desc = 40
178
- if desc_width > max_desc:
179
- desc_width = max_desc
180
-
181
- # Ensure minimum widths match headers
182
- name_width = max(name_width, len("owner/repo"))
183
- desc_width = max(desc_width, len("description"))
184
- time_width = max(time_width, len("updated"))
185
-
186
- # Print header
187
- print(f"\nrlsbl ecosystem ({len(repos)} projects)\n")
188
- header = f" {'owner/repo':<{name_width}} {'description':<{desc_width}} {'updated':<{time_width}}"
189
- print(header)
190
- separator_len = name_width + desc_width + time_width + 6
191
- print(f" {'─' * separator_len}")
192
-
193
- # Print rows
194
- for full_name, description, updated in rows:
195
- # Truncate long descriptions
196
- if len(description) > max_desc:
197
- description = description[:max_desc - 1] + "…"
198
- print(f" {full_name:<{name_width}} {description:<{desc_width}} {updated}")