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.
- package/README.md +117 -112
- package/package.json +3 -5
- package/rlsbl/__init__.py +0 -247
- package/rlsbl/__main__.py +0 -4
- package/rlsbl/commands/__init__.py +0 -0
- package/rlsbl/commands/check.py +0 -229
- package/rlsbl/commands/config.py +0 -67
- package/rlsbl/commands/discover.py +0 -198
- package/rlsbl/commands/init_cmd.py +0 -518
- package/rlsbl/commands/pre_push_check.py +0 -46
- package/rlsbl/commands/record_gif.py +0 -92
- package/rlsbl/commands/release.py +0 -287
- package/rlsbl/commands/status.py +0 -76
- package/rlsbl/commands/undo.py +0 -74
- package/rlsbl/commands/watch.py +0 -125
- package/rlsbl/config.py +0 -57
- package/rlsbl/registries/__init__.py +0 -5
- package/rlsbl/registries/go.py +0 -123
- package/rlsbl/registries/npm.py +0 -119
- package/rlsbl/registries/pypi.py +0 -171
- package/rlsbl/tagging.py +0 -207
- package/rlsbl/templates/go/VERSION.tpl +0 -1
- package/rlsbl/templates/go/ci.yml.tpl +0 -18
- package/rlsbl/templates/go/goreleaser.yml.tpl +0 -25
- package/rlsbl/templates/go/publish.yml.tpl +0 -25
- package/rlsbl/templates/merged/publish.yml.tpl +0 -30
- package/rlsbl/templates/npm/ci.yml.tpl +0 -22
- package/rlsbl/templates/npm/publish.yml.tpl +0 -22
- package/rlsbl/templates/pypi/ci.yml.tpl +0 -20
- package/rlsbl/templates/pypi/publish.yml.tpl +0 -18
- package/rlsbl/templates/shared/CHANGELOG.md.tpl +0 -5
- package/rlsbl/templates/shared/CLAUDE.md.tpl +0 -20
- package/rlsbl/templates/shared/LICENSE.tpl +0 -21
- package/rlsbl/templates/shared/claude-settings.json.tpl +0 -3
- package/rlsbl/templates/shared/gitignore.tpl +0 -14
- package/rlsbl/templates/shared/hooks/post-release.sh.tpl +0 -8
- package/rlsbl/templates/shared/hooks/pre-release.sh.tpl +0 -31
- package/rlsbl/utils.py +0 -131
package/rlsbl/commands/check.py
DELETED
|
@@ -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)
|
package/rlsbl/commands/config.py
DELETED
|
@@ -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}")
|