rlsbl 0.8.3 → 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 -125
  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 -18
  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
@@ -1,518 +0,0 @@
1
- """Init command: scaffold release infrastructure from templates."""
2
-
3
- import hashlib
4
- import json
5
- import os
6
- import re
7
- import subprocess
8
- import sys
9
- import tempfile
10
-
11
- from ..config import should_tag
12
- from ..registries import REGISTRIES
13
- from ..tagging import ensure_tags
14
-
15
- HASHES_FILE = os.path.join(".rlsbl", "hashes.json")
16
- BASES_DIR = os.path.join(".rlsbl", "bases")
17
-
18
- # Files owned by the user after initial scaffold -- never overwrite or merge
19
- USER_OWNED = {
20
- "CHANGELOG.md",
21
- "LICENSE",
22
- ".rlsbl/hooks/pre-release.sh",
23
- ".rlsbl/hooks/post-release.sh",
24
- }
25
-
26
- def file_hash(path):
27
- """SHA-256 hash of a file's contents."""
28
- with open(path, "rb") as f:
29
- return hashlib.sha256(f.read()).hexdigest()
30
-
31
-
32
- def load_hashes():
33
- """Load stored file hashes from .rlsbl/hashes.json."""
34
- if os.path.exists(HASHES_FILE):
35
- with open(HASHES_FILE) as f:
36
- return json.load(f)
37
- return {}
38
-
39
-
40
- def save_hashes(hashes):
41
- """Write file hashes to .rlsbl/hashes.json."""
42
- os.makedirs(os.path.dirname(HASHES_FILE), exist_ok=True)
43
- with open(HASHES_FILE, "w") as f:
44
- json.dump(hashes, f, indent=2)
45
- f.write("\n")
46
-
47
-
48
- NEXT_STEPS = {
49
- "npm": [
50
- "Add an NPM_TOKEN secret to your GitHub repo (Settings > Secrets > Actions)",
51
- "Push to GitHub to activate the CI workflow",
52
- "Run rlsbl release [patch|minor|major]",
53
- ],
54
- "pypi": [
55
- "Push to GitHub",
56
- "Configure Trusted Publishing on pypi.org",
57
- "Run rlsbl release [patch|minor|major]",
58
- ],
59
- "go": [
60
- "GoReleaser runs in CI via GitHub Actions (no local install needed)",
61
- "Push to GitHub to activate the CI workflow",
62
- "Run rlsbl release [patch|minor|major]",
63
- ],
64
- }
65
-
66
-
67
- def process_template(template_content, vars_dict):
68
- """Process a template string by replacing {{varName}} placeholders with values.
69
-
70
- Returns (content, unreplaced) where unreplaced is a list of unmatched var names.
71
- """
72
- unreplaced = []
73
-
74
- def replacer(match):
75
- var_name = match.group(1)
76
- if var_name in vars_dict:
77
- return vars_dict[var_name]
78
- unreplaced.append(var_name)
79
- return match.group(0)
80
-
81
- content = re.sub(r"\{\{(\w+)\}\}", replacer, template_content)
82
- return content, unreplaced
83
-
84
-
85
- def _save_base(target, content):
86
- """Save rendered template content as the merge base for future three-way merges."""
87
- base_path = os.path.join(BASES_DIR, target)
88
- os.makedirs(os.path.dirname(base_path), exist_ok=True)
89
- with open(base_path, "w", encoding="utf-8") as f:
90
- f.write(content)
91
-
92
-
93
- def _load_base(target):
94
- """Load the stored merge base for a target file. Returns None if not stored."""
95
- base_path = os.path.join(BASES_DIR, target)
96
- if not os.path.exists(base_path):
97
- return None
98
- with open(base_path, "r", encoding="utf-8") as f:
99
- return f.read()
100
-
101
-
102
- def _three_way_merge(ours_text, base_text, theirs_text):
103
- """Three-way merge using git merge-file.
104
-
105
- Writes three temp files in the project dir (not /tmp), runs
106
- `git merge-file -p ours base theirs`, and returns (merged_text, has_conflicts).
107
- Exit code: 0 = clean merge, positive = number of conflicts, negative = error.
108
- """
109
- ours_tmp = theirs_tmp = base_tmp = None
110
- try:
111
- ours_tmp = tempfile.NamedTemporaryFile(
112
- mode="w", suffix=".ours", dir=".", delete=False, encoding="utf-8",
113
- )
114
- ours_tmp.write(ours_text)
115
- ours_tmp.close()
116
-
117
- base_tmp = tempfile.NamedTemporaryFile(
118
- mode="w", suffix=".base", dir=".", delete=False, encoding="utf-8",
119
- )
120
- base_tmp.write(base_text)
121
- base_tmp.close()
122
-
123
- theirs_tmp = tempfile.NamedTemporaryFile(
124
- mode="w", suffix=".theirs", dir=".", delete=False, encoding="utf-8",
125
- )
126
- theirs_tmp.write(theirs_text)
127
- theirs_tmp.close()
128
-
129
- result = subprocess.run(
130
- ["git", "merge-file", "-p", ours_tmp.name, base_tmp.name, theirs_tmp.name],
131
- capture_output=True, text=True,
132
- )
133
- merged_text = result.stdout
134
- # Exit code 0 = clean, positive = number of conflicts, negative = error
135
- has_conflicts = result.returncode > 0
136
- if result.returncode < 0:
137
- # Treat errors as conflicts so the caller knows something went wrong
138
- has_conflicts = True
139
- return merged_text, has_conflicts
140
- finally:
141
- for tmp in (ours_tmp, base_tmp, theirs_tmp):
142
- if tmp is not None:
143
- try:
144
- os.unlink(tmp.name)
145
- except OSError:
146
- pass
147
-
148
-
149
- def process_mappings(template_dir, mappings, vars_dict, force, update=False,
150
- existing_hashes=None):
151
- """Process a list of template mappings: read each template, apply vars, write target files.
152
-
153
- Uses a universal three-way merge (via git merge-file) for existing files:
154
- base (last scaffolded version) + ours (user's current file) + theirs (new template).
155
- USER_OWNED files are never overwritten or merged (except LICENSE year update).
156
-
157
- Returns (created, skipped, warnings, new_hashes).
158
- created/skipped are lists of (target, status) tuples for unified display.
159
- """
160
- if existing_hashes is None:
161
- existing_hashes = {}
162
- created = []
163
- skipped = []
164
- warnings = []
165
- new_hashes = {}
166
-
167
- for mapping in mappings:
168
- template = mapping["template"]
169
- target = mapping["target"]
170
-
171
- template_path = os.path.join(template_dir, template)
172
- if not os.path.exists(template_path):
173
- warnings.append(f"Template not found: {template_path}")
174
- continue
175
-
176
- with open(template_path, "r", encoding="utf-8") as f:
177
- raw = f.read()
178
- theirs, unreplaced = process_template(raw, vars_dict)
179
-
180
- # --- New file or force overwrite: write and save base ---
181
- if not os.path.exists(target) or force:
182
- is_overwrite = os.path.exists(target) and force
183
- target_dir = os.path.dirname(target)
184
- if target_dir and target_dir != ".":
185
- os.makedirs(target_dir, exist_ok=True)
186
- with open(target, "w", encoding="utf-8") as f:
187
- f.write(theirs)
188
- _save_base(target, theirs)
189
- new_hashes[target] = file_hash(target)
190
- status = "overwritten" if is_overwrite else "created"
191
- created.append((target, status))
192
- if unreplaced:
193
- warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
194
- continue
195
-
196
- # --- Existing file, not forced ---
197
-
198
- # User-owned files: never touch after initial scaffold,
199
- # except LICENSE gets its copyright year updated on --update.
200
- if target in USER_OWNED:
201
- if update and target == "LICENSE":
202
- from datetime import datetime
203
- current_year = str(datetime.now().year)
204
- with open(target, "r", encoding="utf-8") as f:
205
- content = f.read()
206
- # Match "Copyright (c) YYYY" or "Copyright (c) YYYY-YYYY"
207
- # Capture the original end-year to report the range in the status
208
- old_year = None
209
- def _capture_range(m):
210
- nonlocal old_year
211
- if m.group(2) == current_year:
212
- return m.group(0)
213
- old_year = f"{m.group(1).split()[-1]}-{m.group(2)}"
214
- return f"{m.group(1)}-{current_year}"
215
- updated = re.sub(
216
- r"(Copyright\s+\(c\)\s+\d{4})-(\d{4})",
217
- _capture_range,
218
- content,
219
- )
220
- if updated == content:
221
- # No range found or range already current -- try single year
222
- def _capture_single(m):
223
- nonlocal old_year
224
- if m.group(2) == current_year:
225
- return m.group(0)
226
- old_year = m.group(2)
227
- return f"{m.group(1)}{m.group(2)}-{current_year}"
228
- updated = re.sub(
229
- r"(Copyright\s+\(c\)\s+)(\d{4})(?![-\d])",
230
- _capture_single,
231
- content,
232
- )
233
- if updated != content:
234
- with open(target, "w", encoding="utf-8") as f:
235
- f.write(updated)
236
- year_detail = (
237
- f"year updated ({old_year} -> {old_year.split('-')[0]}-{current_year})"
238
- if old_year and "-" in old_year
239
- else f"year updated ({old_year} -> {old_year}-{current_year})"
240
- ) if old_year else "year updated"
241
- created.append(("LICENSE", year_detail))
242
- else:
243
- skipped.append((target, "user-owned"))
244
- else:
245
- skipped.append((target, "user-owned"))
246
- continue
247
-
248
- # --- Three-way merge for all other existing files ---
249
- with open(target, "r", encoding="utf-8") as f:
250
- ours = f.read()
251
- base = _load_base(target)
252
-
253
- if base is None:
254
- # No base stored (legacy project or first update after migration).
255
- # Cannot do a three-way merge. Seed the base for next time.
256
- _save_base(target, theirs)
257
- if ours == theirs:
258
- skipped.append((target, "unchanged, base seeded"))
259
- else:
260
- warnings.append(
261
- f"{target}: no base stored, cannot merge; "
262
- "run scaffold --force to reset"
263
- )
264
- skipped.append((target, "no base -- run scaffold --force to enable merging"))
265
- continue
266
-
267
- if ours == base:
268
- # User did not customize -- clean update: write theirs.
269
- target_dir = os.path.dirname(target)
270
- if target_dir and target_dir != ".":
271
- os.makedirs(target_dir, exist_ok=True)
272
- with open(target, "w", encoding="utf-8") as f:
273
- f.write(theirs)
274
- _save_base(target, theirs)
275
- new_hashes[target] = file_hash(target)
276
- created.append((target, "updated"))
277
- if unreplaced:
278
- warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
279
- elif base == theirs:
280
- # Template did not change -- nothing to do.
281
- skipped.append((target, "unchanged"))
282
- elif ours == theirs:
283
- # User and template converged to same content -- nothing to do.
284
- skipped.append((target, "unchanged"))
285
- else:
286
- # Both user and template changed -- three-way merge.
287
- merged, has_conflicts = _three_way_merge(ours, base, theirs)
288
- target_dir = os.path.dirname(target)
289
- if target_dir and target_dir != ".":
290
- os.makedirs(target_dir, exist_ok=True)
291
- with open(target, "w", encoding="utf-8") as f:
292
- f.write(merged)
293
- _save_base(target, theirs)
294
- new_hashes[target] = file_hash(target)
295
- if has_conflicts:
296
- created.append((target, "CONFLICTS -- resolve manually"))
297
- warnings.append(f"{target}: merge conflicts detected, resolve manually")
298
- else:
299
- created.append((target, "merged"))
300
- if unreplaced:
301
- warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
302
-
303
- return created, skipped, warnings, new_hashes
304
-
305
-
306
- def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnings,
307
- registry=None, flags=None, registries=None):
308
- """Shared post-processing for scaffold: chmod, hooks, version marker, hashes, tagging, summary.
309
-
310
- all_hash_dicts is a list of dicts to merge into existing_hashes.
311
- flags is the CLI flags dict (used for tagging check).
312
- registries is a list of registry names (used for tagging).
313
- """
314
- if flags is None:
315
- flags = {}
316
- if registries is None:
317
- registries = [registry] if registry else []
318
- # Make all shell scripts in .rlsbl/hooks/ executable
319
- hooks_dir = os.path.join(".", ".rlsbl", "hooks")
320
- if os.path.isdir(hooks_dir):
321
- for entry in os.listdir(hooks_dir):
322
- if entry.endswith(".sh"):
323
- os.chmod(os.path.join(hooks_dir, entry), 0o755)
324
-
325
- # Auto-install pre-push hook as a one-liner that delegates to the subcommand
326
- hook_target = os.path.join(".git", "hooks", "pre-push")
327
- if os.path.isdir(".git"):
328
- if not os.path.exists(hook_target):
329
- hook_content = "#!/usr/bin/env bash\nexec rlsbl pre-push-check \"$@\"\n"
330
- os.makedirs(os.path.join(".git", "hooks"), exist_ok=True)
331
- with open(hook_target, "w", encoding="utf-8") as f:
332
- f.write(hook_content)
333
- os.chmod(hook_target, 0o755)
334
- print("Installed pre-push hook (.git/hooks/pre-push)")
335
-
336
- # Write scaffolding version marker so the pre-push hook can detect drift
337
- from rlsbl import __version__
338
- marker_dir = os.path.join(".", ".rlsbl")
339
- os.makedirs(marker_dir, exist_ok=True)
340
- marker_path = os.path.join(marker_dir, "version")
341
- with open(marker_path, "w") as f:
342
- f.write(__version__ + "\n")
343
- print("Wrote scaffolding version marker (.rlsbl/version)")
344
-
345
- # Persist file hashes for future --update customization detection
346
- all_new_hashes = {}
347
- for h in all_hash_dicts:
348
- all_new_hashes.update(h)
349
- existing_hashes.update(all_new_hashes)
350
- save_hashes(existing_hashes)
351
-
352
- # Ecosystem tagging
353
- if should_tag(flags):
354
- ensure_tags(registries)
355
-
356
- # Print unified file list with dot-padded status column
357
- all_files = [(t, s) for t, s in created] + [(t, s) for t, s in skipped]
358
- if all_files:
359
- # Sort by target path for stable output
360
- all_files.sort(key=lambda item: item[0])
361
- # Compute padding width: longest target path + minimum 4 dots
362
- max_target_len = max(len(t) for t, _ in all_files)
363
- pad_width = max_target_len + 4
364
- print("Files:")
365
- for target, status in all_files:
366
- # Fill gap between target and status with dots
367
- dots = " " + "." * (pad_width - len(target)) + " "
368
- print(f" {target}{dots}{status}")
369
-
370
- if warnings:
371
- print("Warnings:")
372
- for w in warnings:
373
- print(f" {w}")
374
-
375
- # Helpful note when existing CI workflow is preserved
376
- ci_path = ".github/workflows/ci.yml"
377
- if any(t == ci_path for t, _ in skipped):
378
- print("\nNote: Existing CI workflow preserved. Review and merge manually if needed.")
379
-
380
- # Next steps
381
- if registry:
382
- steps = NEXT_STEPS.get(registry)
383
- if steps:
384
- print("\nNext steps:")
385
- for i, step in enumerate(steps, 1):
386
- print(f" {i}. {step}")
387
-
388
-
389
- def run_cmd(registry, args, flags):
390
- """Init command handler.
391
-
392
- Scaffolds release infrastructure (CI, publish workflows, changelog, etc.)
393
- from templates.
394
- """
395
- reg = REGISTRIES[registry]
396
-
397
- # Check that a project file exists
398
- if not reg.check_project_exists("."):
399
- print(f"Error: no {registry} project found in current directory.", file=sys.stderr)
400
- print(reg.get_project_init_hint(), file=sys.stderr)
401
- sys.exit(1)
402
-
403
- # Gather template variables
404
- vars_dict = reg.get_template_vars(".")
405
- from datetime import datetime
406
- vars_dict["year"] = str(datetime.now().year)
407
-
408
- force = flags.get("force", False)
409
- update = flags.get("update", False)
410
-
411
- existing_hashes = load_hashes()
412
-
413
- # Process registry-specific templates
414
- reg_created, reg_skipped, reg_warnings, reg_hashes = process_mappings(
415
- reg.get_template_dir(),
416
- reg.get_template_mappings(),
417
- vars_dict,
418
- force,
419
- update,
420
- existing_hashes,
421
- )
422
-
423
- # Process shared templates (skip if another registry already handled them)
424
- shared_created, shared_skipped, shared_warnings, shared_hashes = [], [], [], {}
425
- if not flags.get("skip-shared"):
426
- shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
427
- reg.get_shared_template_dir(),
428
- reg.get_shared_template_mappings(),
429
- vars_dict,
430
- force,
431
- update,
432
- existing_hashes,
433
- )
434
-
435
- created = reg_created + shared_created
436
- skipped = reg_skipped + shared_skipped
437
- warnings = reg_warnings + shared_warnings
438
-
439
- _finalize_scaffold(
440
- existing_hashes, [reg_hashes, shared_hashes],
441
- created, skipped, warnings, registry=registry,
442
- flags=flags, registries=[registry],
443
- )
444
-
445
-
446
- def run_cmd_multi(registries_list, args, flags):
447
- """Scaffold for multiple registries with a merged publish workflow.
448
-
449
- Uses the primary registry for template vars and CI, then writes a merged
450
- publish.yml that contains jobs for all detected registries.
451
- """
452
- primary = registries_list[0]
453
- reg = REGISTRIES[primary]
454
-
455
- if not reg.check_project_exists("."):
456
- print(f"Error: no {primary} project found in current directory.", file=sys.stderr)
457
- sys.exit(1)
458
-
459
- print(f"Multiple registries detected: {', '.join(registries_list)}")
460
- print("Scaffolding with merged publish workflow.")
461
-
462
- vars_dict = reg.get_template_vars(".")
463
- from datetime import datetime
464
- vars_dict["year"] = str(datetime.now().year)
465
-
466
- force = flags.get("force", False)
467
- update = flags.get("update", False)
468
- existing_hashes = load_hashes()
469
-
470
- # Process primary registry CI template only (publish will come from merged)
471
- ci_mappings = [m for m in reg.get_template_mappings() if "publish" not in m["template"]]
472
- ci_created, ci_skipped, ci_warnings, ci_hashes = process_mappings(
473
- reg.get_template_dir(),
474
- ci_mappings,
475
- vars_dict,
476
- force,
477
- update,
478
- existing_hashes,
479
- )
480
-
481
- # Process merged publish workflow template
482
- merged_tpl_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)),
483
- "templates", "merged")
484
- merged_created, merged_skipped, merged_warnings, merged_hashes = process_mappings(
485
- merged_tpl_dir,
486
- [{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"}],
487
- vars_dict,
488
- force,
489
- update,
490
- existing_hashes,
491
- )
492
-
493
- # Process shared templates (once)
494
- shared_created, shared_skipped, shared_warnings, shared_hashes = process_mappings(
495
- reg.get_shared_template_dir(),
496
- reg.get_shared_template_mappings(),
497
- vars_dict,
498
- force,
499
- update,
500
- existing_hashes,
501
- )
502
-
503
- created = ci_created + merged_created + shared_created
504
- skipped = ci_skipped + merged_skipped + shared_skipped
505
- warnings = ci_warnings + merged_warnings + shared_warnings
506
-
507
- _finalize_scaffold(
508
- existing_hashes, [ci_hashes, merged_hashes, shared_hashes],
509
- created, skipped, warnings,
510
- flags=flags, registries=registries_list,
511
- )
512
-
513
- # Show combined next steps for dual-registry
514
- print("\nNext steps:")
515
- print(" 1. Add an NPM_TOKEN secret to your GitHub repo (Settings > Secrets > Actions)")
516
- print(" 2. Configure Trusted Publishing on pypi.org")
517
- print(" 3. Push to GitHub to activate the CI workflow")
518
- print(" 4. Run rlsbl release [patch|minor|major]")
@@ -1,46 +0,0 @@
1
- """Pre-push-check command: verify CHANGELOG.md has an entry for the current version."""
2
-
3
- import os
4
- import re
5
- import sys
6
-
7
- from ..registries import REGISTRIES
8
-
9
-
10
- def _detect_version():
11
- """Detect version using registry adapters.
12
-
13
- Returns (version_string, registry_name) or (None, None) if undetectable.
14
- """
15
- for name in ("go", "npm", "pypi"):
16
- reg = REGISTRIES[name]
17
- if reg.check_project_exists("."):
18
- return reg.read_version("."), name
19
- return None, None
20
-
21
-
22
- def run_cmd(registry, args, flags):
23
- """Check that CHANGELOG.md has an entry for the current project version.
24
-
25
- Exits 1 if no changelog entry is found; exits 0 silently on success.
26
- """
27
- version, _project_type = _detect_version()
28
- if not version:
29
- # Cannot detect version -- nothing to check
30
- sys.exit(0)
31
-
32
- if not os.path.exists("CHANGELOG.md"):
33
- # No changelog file -- nothing to check
34
- sys.exit(0)
35
-
36
- with open("CHANGELOG.md", "r", encoding="utf-8") as f:
37
- content = f.read()
38
-
39
- # Look for a heading like "## <version>"
40
- pattern = re.compile(r"^## " + re.escape(version) + r"\s*$", re.MULTILINE)
41
- if pattern.search(content):
42
- sys.exit(0)
43
-
44
- print(f"Error: CHANGELOG.md has no entry for version {version}.", file=sys.stderr)
45
- print(f"Add a '## {version}' section before pushing.", file=sys.stderr)
46
- sys.exit(1)
@@ -1,92 +0,0 @@
1
- """Record-gif command: record a demo GIF using vhs."""
2
-
3
- import os
4
- import shutil
5
- import subprocess
6
- import sys
7
- import tempfile
8
-
9
- from .. import detect_registries
10
- from ..registries import REGISTRIES
11
-
12
-
13
- def _get_bin_command():
14
- """Auto-detect the project's binary command name via registry template vars."""
15
- regs = detect_registries()
16
- if not regs:
17
- return None
18
- # Use the first detected registry
19
- registry_module = REGISTRIES.get(regs[0])
20
- if not registry_module:
21
- return None
22
- try:
23
- tvars = registry_module.get_template_vars(".")
24
- return tvars.get("binCommand") or None
25
- except Exception:
26
- return None
27
-
28
-
29
- def run_cmd(registry, args, flags):
30
- """Record a demo GIF of '<binCommand> --help' using vhs.
31
-
32
- Requires vhs (https://github.com/charmbracelet/vhs) to be installed.
33
- Output is saved to assets/demo.gif.
34
- """
35
- if not shutil.which("vhs"):
36
- print("Error: vhs is required.", file=sys.stderr)
37
- print("Install: go install github.com/charmbracelet/vhs@latest", file=sys.stderr)
38
- sys.exit(1)
39
-
40
- bin_command = _get_bin_command()
41
- if not bin_command:
42
- print("Error: could not detect project binary command.", file=sys.stderr)
43
- print("Ensure package.json, pyproject.toml, or go.mod exists with a CLI entry point.", file=sys.stderr)
44
- sys.exit(1)
45
-
46
- # Parse configurable VHS parameters from flags
47
- width = int(flags.get("width", 1200))
48
- height = int(flags.get("height", 600))
49
- font_size = int(flags.get("font-size", 24))
50
- duration = int(flags.get("duration", 10))
51
-
52
- assets_dir = "assets"
53
- os.makedirs(assets_dir, exist_ok=True)
54
-
55
- # Create a temporary VHS tape file in the project directory
56
- tape_content = (
57
- 'Set FontFamily "monospace"\n'
58
- f"Set FontSize {font_size}\n"
59
- f"Set Width {width}\n"
60
- f"Set Height {height}\n"
61
- "Set TypingSpeed 50ms\n"
62
- f'Type "{bin_command} --help"\n'
63
- "Enter\n"
64
- f"Sleep {duration}s\n"
65
- )
66
-
67
- tape_fd, tape_path = tempfile.mkstemp(suffix=".tape", dir=".")
68
- try:
69
- with os.fdopen(tape_fd, "w") as f:
70
- f.write(tape_content)
71
-
72
- output_path = os.path.join(assets_dir, "demo.gif")
73
- print("Recording demo...")
74
-
75
- subprocess.run(
76
- ["vhs", tape_path, "-o", output_path],
77
- check=True, timeout=120,
78
- )
79
-
80
- print(f"Done. GIF saved to {output_path}")
81
- except subprocess.CalledProcessError:
82
- print("Error: vhs recording failed.", file=sys.stderr)
83
- sys.exit(1)
84
- except subprocess.TimeoutExpired:
85
- print("Error: vhs recording timed out after 120s.", file=sys.stderr)
86
- sys.exit(1)
87
- finally:
88
- # Clean up the temp tape file
89
- try:
90
- os.unlink(tape_path)
91
- except OSError:
92
- pass