gestate 1.0.0__py3-none-any.whl

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.
gestate/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Configure package managers with a minimum release age."""
2
+
3
+ __version__ = "1.0.0"
gestate/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
gestate/cli.py ADDED
@@ -0,0 +1,713 @@
1
+ """Rich-formatted CLI orchestrator."""
2
+
3
+ import argparse
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.prompt import Confirm, IntPrompt, Prompt
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from .config import (
15
+ remove_deno_alias,
16
+ remove_uv_env_export,
17
+ set_bunfig_min_age,
18
+ set_deno_alias,
19
+ set_uv_exclude_newer,
20
+ unset_bunfig_min_age,
21
+ unset_uv_exclude_newer,
22
+ )
23
+ from .detect import (
24
+ bunfig_path,
25
+ detect_profile,
26
+ detect_tools,
27
+ uv_config_path,
28
+ yarn_major,
29
+ )
30
+ from .status import (
31
+ bun_current,
32
+ deno_current_alias,
33
+ npm_current,
34
+ pip_current,
35
+ pnpm_current,
36
+ uv_current,
37
+ yarn_current,
38
+ )
39
+
40
+ console = Console()
41
+
42
+ WHY_INTRO = (
43
+ "Malicious package versions are usually caught and yanked within days. "
44
+ "Refusing fresh versions blocks most supply-chain attacks.\n\n"
45
+ "[bold]gestate[/] sets a minimum release age across your installed "
46
+ "package managers."
47
+ )
48
+
49
+ TOOL_ORDER = ["npm", "pnpm", "yarn", "bun", "deno", "pip", "uv"]
50
+ FILE_BASED = {"bun", "deno", "uv"}
51
+ DENO_OUR_PREFIX = "alias deno='command deno --minimum-dependency-age="
52
+
53
+ TOOL_META = {
54
+ "npm": {
55
+ "kind": "CLI config",
56
+ "config": "~/.npmrc (user)",
57
+ "key": "min-release-age",
58
+ "unit": "days",
59
+ "set": "npm config set min-release-age=<N> --location=user",
60
+ "revert": "npm config delete min-release-age --location=user",
61
+ },
62
+ "pnpm": {
63
+ "kind": "CLI config",
64
+ "config": "global pnpm config",
65
+ "key": "minimumReleaseAge",
66
+ "unit": "minutes (= days × 1440)",
67
+ "set": "pnpm config set minimumReleaseAge <N×1440> --global",
68
+ "revert": "pnpm config delete minimumReleaseAge --global",
69
+ },
70
+ "yarn": {
71
+ "kind": "CLI config (yarn 4.10+)",
72
+ "config": "~/.yarnrc.yml",
73
+ "key": "npmMinimalAgeGate",
74
+ "unit": "minutes (= days × 1440)",
75
+ "set": "yarn config set --home npmMinimalAgeGate <N×1440>",
76
+ "revert": "yarn config unset --home npmMinimalAgeGate",
77
+ },
78
+ "bun": {
79
+ "kind": "TOML file",
80
+ "config": "~/.bunfig.toml",
81
+ "key": "[install] minimumReleaseAge",
82
+ "unit": "seconds (= days × 86400)",
83
+ "set": "parse TOML, set install.minimumReleaseAge = <N×86400>, re-emit",
84
+ "revert": "remove key (and [install] table if empty); delete file if empty",
85
+ },
86
+ "deno": {
87
+ "kind": "shell-profile alias",
88
+ "config": "~/.zshrc, ~/.bashrc, or ~/.profile",
89
+ "key": "alias deno=…",
90
+ "unit": "ISO 8601 duration (P<N>D)",
91
+ "set": "alias deno='command deno --minimum-dependency-age=P<N>D'",
92
+ "revert": "remove our alias line (leaves foreign aliases alone)",
93
+ },
94
+ "pip": {
95
+ "kind": "CLI config",
96
+ "config": "user pip config",
97
+ "key": "global.uploaded-prior-to",
98
+ "unit": "ISO 8601 duration (P<N>D)",
99
+ "set": "pip config set global.uploaded-prior-to P<N>D",
100
+ "revert": "pip config unset global.uploaded-prior-to",
101
+ },
102
+ "uv": {
103
+ "kind": "TOML file",
104
+ "config": "~/.config/uv/uv.toml",
105
+ "key": "exclude-newer",
106
+ "unit": "relative duration string ('N days')",
107
+ "set": 'parse TOML, set exclude-newer = "<N> days", re-emit',
108
+ "revert": "remove key; delete file if empty",
109
+ },
110
+ }
111
+
112
+
113
+ def _plain() -> bool:
114
+ return not console.is_terminal
115
+
116
+
117
+ def _strip_markup(value: str) -> str:
118
+ return Text.from_markup(value).plain
119
+
120
+
121
+ def targets_for(days: int) -> dict[str, tuple[str, str]]:
122
+ """Map tool → (target_value, target_display) for the given delay in days."""
123
+ mins = days * 1440
124
+ secs = days * 86400
125
+ return {
126
+ "npm": (str(days), f"min-release-age={days}"),
127
+ "pnpm": (str(mins), f"minimumReleaseAge={mins}"),
128
+ "yarn": (str(mins), f"npmMinimalAgeGate={mins}"),
129
+ "bun": (str(secs), f"minimumReleaseAge={secs}"),
130
+ "deno": (f"P{days}D", f"--minimum-dependency-age=P{days}D"),
131
+ "pip": (f"P{days}D", f"global.uploaded-prior-to=P{days}D"),
132
+ "uv": (f"{days} days", f'exclude-newer = "{days} days"'),
133
+ }
134
+
135
+
136
+ def _read_current(tool: str) -> str | None:
137
+ if tool == "npm":
138
+ return npm_current()
139
+ if tool == "pnpm":
140
+ return pnpm_current()
141
+ if tool == "yarn":
142
+ return yarn_current()
143
+ if tool == "bun":
144
+ return bun_current(bunfig_path())
145
+ if tool == "deno":
146
+ return deno_current_alias(detect_profile())
147
+ if tool == "pip":
148
+ return pip_current()
149
+ if tool == "uv":
150
+ return uv_current(uv_config_path())
151
+ return None
152
+
153
+
154
+ def _current_label(
155
+ tool: str,
156
+ tools: dict[str, bool],
157
+ currents: dict[str, str | None],
158
+ yarn_supported: bool,
159
+ ) -> str:
160
+ if not tools.get(tool):
161
+ return "[dim]not installed[/]"
162
+ if tool == "yarn" and not yarn_supported:
163
+ return "[yellow]yarn <4.10 (unsupported)[/]"
164
+ if currents[tool] is None:
165
+ return "[dim]unset[/]"
166
+ return currents[tool]
167
+
168
+
169
+ def _print_current(
170
+ tools: dict[str, bool],
171
+ currents: dict[str, str | None],
172
+ yarn_supported: bool = True,
173
+ ) -> None:
174
+ if _plain():
175
+ print("Current:")
176
+ for tool in TOOL_ORDER:
177
+ label = _strip_markup(_current_label(tool, tools, currents, yarn_supported))
178
+ print(f" {tool:<6} {label}")
179
+ return
180
+ table = Table(title="Current release-age settings", header_style="bold yellow")
181
+ table.add_column("Tool", style="cyan")
182
+ table.add_column("Current")
183
+ for tool in TOOL_ORDER:
184
+ table.add_row(tool, _current_label(tool, tools, currents, yarn_supported))
185
+ console.print(table)
186
+
187
+
188
+ def build_rows(
189
+ tools: dict[str, bool],
190
+ currents: dict[str, str | None],
191
+ targets: dict[str, tuple[str, str]],
192
+ scope: str,
193
+ yarn_supported: bool = True,
194
+ ) -> list[tuple[str, str, str, bool]]:
195
+ """Build (tool, current_display, action_display, needs_apply) rows."""
196
+ rows = []
197
+ for tool in TOOL_ORDER:
198
+ target_val, target_disp = targets[tool]
199
+ installed = tools.get(tool, False)
200
+ current = currents.get(tool)
201
+
202
+ if not installed:
203
+ if scope == "all" and tool in FILE_BASED:
204
+ rows.append(
205
+ (
206
+ tool,
207
+ "[dim]not installed[/]",
208
+ f"[cyan]pre-configure[/] {target_disp}",
209
+ True,
210
+ )
211
+ )
212
+ else:
213
+ rows.append((tool, "[dim]not installed[/]", "[dim]skip[/]", False))
214
+ continue
215
+
216
+ if tool == "yarn" and not yarn_supported:
217
+ rows.append(
218
+ (tool, "[yellow]yarn <4.10 (unsupported)[/]", "[dim]skip[/]", False)
219
+ )
220
+ continue
221
+
222
+ if tool == "deno":
223
+ if current is None:
224
+ rows.append(
225
+ (tool, "[dim]unset[/]", f"[green]set[/] {target_disp}", True)
226
+ )
227
+ elif not current.startswith(DENO_OUR_PREFIX):
228
+ rows.append((tool, current, "[red]conflict[/] — merge manually", False))
229
+ elif f"minimum-dependency-age={target_val}'" in current:
230
+ rows.append((tool, current, "[dim]already set[/]", False))
231
+ else:
232
+ rows.append((tool, current, f"[yellow]replace[/] → {target_val}", True))
233
+ continue
234
+
235
+ if current is None:
236
+ rows.append((tool, "[dim]unset[/]", f"[green]set[/] {target_disp}", True))
237
+ elif current == target_val:
238
+ rows.append((tool, current, "[dim]already set[/]", False))
239
+ else:
240
+ rows.append((tool, current, f"[yellow]replace[/] → {target_val}", True))
241
+ return rows
242
+
243
+
244
+ def _print_plan(
245
+ rows: list[tuple[str, str, str, bool]], *, title: str = "Proposed changes"
246
+ ) -> None:
247
+ if _plain():
248
+ print(f"{title}:")
249
+ for tool, current, action, _ in rows:
250
+ print(f" {tool:<6} {_strip_markup(current):<28} {_strip_markup(action)}")
251
+ return
252
+ table = Table(title=title, header_style="bold yellow")
253
+ table.add_column("Tool", style="cyan")
254
+ table.add_column("Current")
255
+ table.add_column("Action")
256
+ for tool, current, action, _ in rows:
257
+ table.add_row(tool, current, action)
258
+ console.print(table)
259
+
260
+
261
+ def _apply_npm(days: int) -> None:
262
+ subprocess.run(
263
+ ["npm", "config", "set", f"min-release-age={days}", "--location=user"],
264
+ check=True,
265
+ )
266
+
267
+
268
+ def _apply_pnpm(days: int) -> None:
269
+ subprocess.run(
270
+ ["pnpm", "config", "set", "minimumReleaseAge", str(days * 1440), "--global"],
271
+ check=True,
272
+ )
273
+
274
+
275
+ def _apply_yarn(days: int) -> None:
276
+ subprocess.run(
277
+ ["yarn", "config", "set", "--home", "npmMinimalAgeGate", str(days * 1440)],
278
+ check=True,
279
+ )
280
+
281
+
282
+ def _apply_pip(days: int) -> None:
283
+ pip = "pip" if shutil.which("pip") else "pip3"
284
+ subprocess.run(
285
+ [pip, "config", "set", "global.uploaded-prior-to", f"P{days}D"],
286
+ check=True,
287
+ capture_output=True,
288
+ )
289
+
290
+
291
+ def _run(failures: list[str], tool: str, action) -> None:
292
+ try:
293
+ action()
294
+ except subprocess.CalledProcessError as exc:
295
+ failures.append(tool)
296
+ stderr = (
297
+ (exc.stderr or b"").decode(errors="replace")
298
+ if isinstance(exc.stderr, bytes)
299
+ else (exc.stderr or "")
300
+ )
301
+ tail = (
302
+ stderr.strip().splitlines()[-1]
303
+ if stderr.strip()
304
+ else f"exit {exc.returncode}"
305
+ )
306
+ console.print(f"[red]{tool}[/] failed: {tail}")
307
+ except OSError as exc:
308
+ failures.append(tool)
309
+ console.print(f"[red]{tool}[/] failed: {exc}")
310
+
311
+
312
+ def _revert_npm() -> None:
313
+ subprocess.run(
314
+ ["npm", "config", "delete", "min-release-age", "--location=user"], check=True
315
+ )
316
+
317
+
318
+ def _revert_pnpm() -> None:
319
+ subprocess.run(
320
+ ["pnpm", "config", "delete", "minimumReleaseAge", "--global"], check=True
321
+ )
322
+
323
+
324
+ def _revert_yarn() -> None:
325
+ subprocess.run(
326
+ ["yarn", "config", "unset", "--home", "npmMinimalAgeGate"], check=True
327
+ )
328
+
329
+
330
+ def _revert_pip() -> None:
331
+ pip = "pip" if shutil.which("pip") else "pip3"
332
+ subprocess.run(
333
+ [pip, "config", "unset", "global.uploaded-prior-to"],
334
+ check=True,
335
+ capture_output=True,
336
+ )
337
+
338
+
339
+ def build_revert_rows(
340
+ tools: dict[str, bool],
341
+ currents: dict[str, str | None],
342
+ yarn_supported: bool = True,
343
+ ) -> list[tuple[str, str, str, bool]]:
344
+ """Build (tool, current_display, action_display, needs_apply) rows for revert."""
345
+ cli_tools = {"npm", "pnpm", "yarn", "pip"}
346
+ rows = []
347
+ for tool in TOOL_ORDER:
348
+ current = currents.get(tool)
349
+ installed = tools.get(tool, False)
350
+
351
+ if tool == "yarn" and installed and not yarn_supported:
352
+ rows.append(
353
+ (tool, "[yellow]yarn <4.10 (unsupported)[/]", "[dim]skip[/]", False)
354
+ )
355
+ continue
356
+
357
+ if tool in cli_tools:
358
+ if not installed:
359
+ rows.append((tool, "[dim]not installed[/]", "[dim]skip[/]", False))
360
+ elif current is None:
361
+ rows.append((tool, "[dim]unset[/]", "[dim]skip[/]", False))
362
+ else:
363
+ rows.append((tool, current, f"[yellow]unset[/] (was {current})", True))
364
+ continue
365
+
366
+ if tool == "deno":
367
+ if current is None:
368
+ rows.append((tool, "[dim]no alias[/]", "[dim]skip[/]", False))
369
+ elif current.startswith(DENO_OUR_PREFIX):
370
+ rows.append((tool, current, "[yellow]remove alias[/]", True))
371
+ else:
372
+ rows.append(
373
+ (tool, current, "[red]foreign alias[/] — leave alone", False)
374
+ )
375
+ continue
376
+
377
+ # bun, uv — file-based, act regardless of binary presence
378
+ if current is None:
379
+ rows.append((tool, "[dim]unset[/]", "[dim]skip[/]", False))
380
+ else:
381
+ rows.append((tool, current, f"[yellow]remove[/] (was {current})", True))
382
+ return rows
383
+
384
+
385
+ def _read_currents(tools: dict[str, bool]) -> dict[str, str | None]:
386
+ """Read each tool's current value. File-based tools read regardless of binary presence."""
387
+ out: dict[str, str | None] = {}
388
+ for tool in TOOL_ORDER:
389
+ if tool in FILE_BASED or tools.get(tool):
390
+ out[tool] = _read_current(tool)
391
+ else:
392
+ out[tool] = None
393
+ return out
394
+
395
+
396
+ def _do_revert(
397
+ tools: dict[str, bool],
398
+ yarn_ok: bool,
399
+ currents: dict[str, str | None],
400
+ *,
401
+ confirm: bool = True,
402
+ ) -> int:
403
+ rows = build_revert_rows(tools, currents, yarn_supported=yarn_ok)
404
+ _print_plan(rows, title="Revert plan")
405
+
406
+ pending = {r[0] for r in rows if r[3]}
407
+ if not pending:
408
+ console.print("\n[bold green]Nothing to revert.[/]")
409
+ return 0
410
+
411
+ if confirm and not Confirm.ask(f"\nRevert {len(pending)} setting(s)?"):
412
+ console.print("[red]Aborted.[/]")
413
+ return 1
414
+
415
+ console.print()
416
+ failures: list[str] = []
417
+
418
+ if "npm" in pending:
419
+ _run(
420
+ failures,
421
+ "npm",
422
+ lambda: (_revert_npm(), console.print("[green]npm[/] unset")),
423
+ )
424
+ if "pnpm" in pending:
425
+ _run(
426
+ failures,
427
+ "pnpm",
428
+ lambda: (_revert_pnpm(), console.print("[green]pnpm[/] unset")),
429
+ )
430
+ if "yarn" in pending:
431
+ _run(
432
+ failures,
433
+ "yarn",
434
+ lambda: (_revert_yarn(), console.print("[green]yarn[/] unset")),
435
+ )
436
+ if "bun" in pending:
437
+ bunfig = bunfig_path()
438
+
439
+ def _bun() -> None:
440
+ unset_bunfig_min_age(bunfig)
441
+ msg = "removed" if not bunfig.exists() else "key removed"
442
+ console.print(f"[green]bun[/] {msg} ({bunfig})")
443
+
444
+ _run(failures, "bun", _bun)
445
+ if "deno" in pending:
446
+ profile = detect_profile()
447
+
448
+ def _deno() -> None:
449
+ state = remove_deno_alias(profile)
450
+ if state == "removed":
451
+ console.print(
452
+ f"[green]deno[/] alias removed from {profile} (backup at {profile}.bak)"
453
+ )
454
+ else:
455
+ console.print(f"[yellow]deno[/] {state}")
456
+
457
+ _run(failures, "deno", _deno)
458
+ if "pip" in pending:
459
+ _run(
460
+ failures,
461
+ "pip",
462
+ lambda: (_revert_pip(), console.print("[green]pip[/] unset")),
463
+ )
464
+ if "uv" in pending:
465
+ uv_cfg = uv_config_path()
466
+
467
+ def _uv() -> None:
468
+ unset_uv_exclude_newer(uv_cfg)
469
+ msg = "removed" if not uv_cfg.exists() else "key removed"
470
+ console.print(f"[green]uv[/] {msg} ({uv_cfg})")
471
+
472
+ _run(failures, "uv", _uv)
473
+
474
+ if failures:
475
+ console.print(f"\n[bold red]Finished with errors:[/] {', '.join(failures)}")
476
+ return 1
477
+ console.print("\n[bold green]Reverted.[/]")
478
+ return 0
479
+
480
+
481
+ def _do_set(
482
+ tools: dict[str, bool],
483
+ yarn_ok: bool,
484
+ currents: dict[str, str | None],
485
+ *,
486
+ days: int | None = None,
487
+ scope: str | None = None,
488
+ confirm: bool = True,
489
+ ) -> int:
490
+ if days is None:
491
+ days = IntPrompt.ask("Minimum release age (days)", default=3)
492
+ if days < 1:
493
+ console.print("[red]Days must be ≥ 1.[/]")
494
+ return 1
495
+
496
+ if scope is None:
497
+ scope = Prompt.ask(
498
+ "Apply to", choices=["installed", "all"], default="installed"
499
+ )
500
+
501
+ targets = targets_for(days)
502
+ rows = build_rows(tools, currents, targets, scope, yarn_supported=yarn_ok)
503
+
504
+ console.print()
505
+ _print_plan(rows)
506
+
507
+ pending = {r[0] for r in rows if r[3]}
508
+ if not pending:
509
+ console.print("\n[bold green]Nothing to change.[/]")
510
+ return 0
511
+
512
+ pending_in_order = [r[0] for r in rows if r[3]]
513
+ console.print(
514
+ f"\n[dim italic]Will apply a {days}-day minimum release age to: "
515
+ + ", ".join(pending_in_order)
516
+ + "[/]"
517
+ )
518
+ if confirm and not Confirm.ask(f"Apply {len(pending)} change(s)?"):
519
+ console.print("[red]Aborted. No changes made.[/]")
520
+ return 1
521
+
522
+ console.print()
523
+ failures: list[str] = []
524
+
525
+ if "npm" in pending:
526
+ _run(
527
+ failures,
528
+ "npm",
529
+ lambda: (_apply_npm(days), console.print("[green]npm[/] configured")),
530
+ )
531
+ if "pnpm" in pending:
532
+ _run(
533
+ failures,
534
+ "pnpm",
535
+ lambda: (_apply_pnpm(days), console.print("[green]pnpm[/] configured")),
536
+ )
537
+ if "yarn" in pending:
538
+ _run(
539
+ failures,
540
+ "yarn",
541
+ lambda: (_apply_yarn(days), console.print("[green]yarn[/] configured")),
542
+ )
543
+ if "bun" in pending:
544
+ bunfig = bunfig_path()
545
+ _run(
546
+ failures,
547
+ "bun",
548
+ lambda: (
549
+ set_bunfig_min_age(bunfig, days * 86400),
550
+ console.print(f"[green]bun[/] configured ({bunfig})"),
551
+ ),
552
+ )
553
+ if "deno" in pending:
554
+ profile = detect_profile()
555
+
556
+ def _deno() -> None:
557
+ state = set_deno_alias(profile, f"P{days}D")
558
+ if state == "added":
559
+ console.print(
560
+ f"[green]deno[/] alias added to {profile} — reload shell to activate"
561
+ )
562
+ elif state == "updated":
563
+ console.print(
564
+ f"[green]deno[/] alias updated in {profile} (backup at {profile}.bak) — reload shell"
565
+ )
566
+ else:
567
+ console.print(f"[yellow]deno[/] {state}")
568
+
569
+ _run(failures, "deno", _deno)
570
+ if "pip" in pending:
571
+ _run(
572
+ failures,
573
+ "pip",
574
+ lambda: (_apply_pip(days), console.print("[green]pip[/] configured")),
575
+ )
576
+ if "uv" in pending:
577
+ uv_cfg = uv_config_path()
578
+
579
+ def _uv() -> None:
580
+ set_uv_exclude_newer(uv_cfg, f"{days} days")
581
+ console.print(f"[green]uv[/] configured ({uv_cfg})")
582
+ profile = detect_profile()
583
+ if remove_uv_env_export(profile):
584
+ console.print(
585
+ f"[green]uv[/] removed stale UV_EXCLUDE_NEWER from {profile} (backup at {profile}.bak)"
586
+ )
587
+
588
+ _run(failures, "uv", _uv)
589
+
590
+ if failures:
591
+ console.print(f"\n[bold red]Finished with errors:[/] {', '.join(failures)}")
592
+ return 1
593
+ console.print("\n[bold green]Done.[/] Protected against fresh package versions.")
594
+ return 0
595
+
596
+
597
+ def _do_explain(tool: str) -> int:
598
+ meta = TOOL_META[tool]
599
+ current = _read_current(tool)
600
+ current_text = current if current is not None else "(unset)"
601
+
602
+ if _plain():
603
+ print(f"{tool} ({meta['kind']})")
604
+ print(f" config: {meta['config']}")
605
+ print(f" key: {meta['key']}")
606
+ print(f" unit: {meta['unit']}")
607
+ print(f" current: {current_text}")
608
+ print(f" set: {meta['set']}")
609
+ print(f" revert: {meta['revert']}")
610
+ return 0
611
+
612
+ console.print(f"\n[bold cyan]{tool}[/] [dim]({meta['kind']})[/]")
613
+ console.print(f" [bold]config[/]: {meta['config']}")
614
+ console.print(f" [bold]key[/]: {meta['key']} [dim]({meta['unit']})[/]")
615
+ console.print(
616
+ " [bold]current[/]: "
617
+ + (current_text if current is not None else "[dim](unset)[/]")
618
+ )
619
+ console.print(f" [bold]set[/]: [dim]{meta['set']}[/]")
620
+ console.print(f" [bold]revert[/]: [dim]{meta['revert']}[/]")
621
+ return 0
622
+
623
+
624
+ def _main() -> int:
625
+ parser = argparse.ArgumentParser(
626
+ prog="gestate",
627
+ description="Configure package managers with a minimum release age.",
628
+ )
629
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
630
+
631
+ set_p = sub.add_parser(
632
+ "set", help="Set a minimum release age and apply (non-interactive)."
633
+ )
634
+ set_p.add_argument("days", type=int, help="Minimum release age in days.")
635
+ set_p.add_argument(
636
+ "--all",
637
+ action="store_true",
638
+ help="Also pre-configure file-based tools (bun, deno, uv) when their binary is missing.",
639
+ )
640
+
641
+ sub.add_parser("revert", help="Remove gestate's settings (non-interactive).")
642
+
643
+ explain_p = sub.add_parser(
644
+ "explain",
645
+ help="Show how a tool's setting is configured and what gestate does to it.",
646
+ )
647
+ explain_p.add_argument("tool", choices=TOOL_ORDER)
648
+
649
+ args = parser.parse_args()
650
+
651
+ if args.command == "explain":
652
+ return _do_explain(args.tool)
653
+
654
+ if args.command is None and not sys.stdin.isatty():
655
+ console.print(
656
+ "[red]No TTY detected.[/] Run [bold]gestate set <days>[/] or "
657
+ "[bold]gestate revert[/] for non-interactive use."
658
+ )
659
+ return 2
660
+
661
+ with console.status("[bold blue]Scanning package managers..."):
662
+ tools = detect_tools()
663
+ yarn_ok = (yarn_major() or 0) >= 4
664
+ currents = _read_currents(tools)
665
+
666
+ if args.command == "set":
667
+ if args.days < 1:
668
+ console.print("[red]days must be ≥ 1[/]")
669
+ return 1
670
+ scope = "all" if args.all else "installed"
671
+ return _do_set(
672
+ tools,
673
+ yarn_ok,
674
+ currents,
675
+ days=args.days,
676
+ scope=scope,
677
+ confirm=False,
678
+ )
679
+
680
+ if args.command == "revert":
681
+ return _do_revert(tools, yarn_ok, currents, confirm=False)
682
+
683
+ _print_current(tools, currents, yarn_supported=yarn_ok)
684
+
685
+ if all(currents[t] is None for t in TOOL_ORDER):
686
+ console.print()
687
+ console.print(
688
+ Panel(WHY_INTRO, title="Why minimum release age?", border_style="cyan")
689
+ )
690
+
691
+ console.print(
692
+ "\n[dim italic]Nothing has changed yet — you'll see a full plan and be asked "
693
+ "to confirm before anything is applied.[/]"
694
+ )
695
+
696
+ action = Prompt.ask("Action", choices=["set", "revert", "quit"], default="set")
697
+ if action == "quit":
698
+ return 0
699
+ if action == "revert":
700
+ return _do_revert(tools, yarn_ok, currents)
701
+ return _do_set(tools, yarn_ok, currents)
702
+
703
+
704
+ def main() -> int:
705
+ try:
706
+ return _main()
707
+ except (KeyboardInterrupt, EOFError):
708
+ console.print("\n[red]Aborted.[/]")
709
+ return 130
710
+
711
+
712
+ if __name__ == "__main__":
713
+ sys.exit(main())
gestate/config.py ADDED
@@ -0,0 +1,135 @@
1
+ """Config-file editors. Pure functions over given paths — no subprocess, no globals."""
2
+
3
+ import re
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+ import tomli_w
8
+
9
+ _DENO_OUR_LINE = re.compile(
10
+ r"^alias deno='command deno --minimum-dependency-age=[^']+'$",
11
+ re.MULTILINE,
12
+ )
13
+
14
+
15
+ def _load(path: Path) -> dict:
16
+ if not path.exists():
17
+ return {}
18
+ with path.open("rb") as f:
19
+ return tomllib.load(f)
20
+
21
+
22
+ def _dump(path: Path, data: dict) -> None:
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ with path.open("wb") as f:
25
+ tomli_w.dump(data, f)
26
+
27
+
28
+ def set_bunfig_min_age(path: Path, seconds: int) -> bool:
29
+ data = _load(path)
30
+ install = data.setdefault("install", {})
31
+ if install.get("minimumReleaseAge") == seconds:
32
+ return False
33
+ install["minimumReleaseAge"] = seconds
34
+ _dump(path, data)
35
+ return True
36
+
37
+
38
+ def set_uv_exclude_newer(path: Path, value: str) -> bool:
39
+ data = _load(path)
40
+ if data.get("exclude-newer") == value:
41
+ return False
42
+ data["exclude-newer"] = value
43
+ _dump(path, data)
44
+ return True
45
+
46
+
47
+ def remove_uv_env_export(profile: Path) -> bool:
48
+ """Strip stale UV_EXCLUDE_NEWER exports from a shell profile, saving a `.bak`."""
49
+ if not profile.exists():
50
+ return False
51
+ original = profile.read_text()
52
+ kept = [
53
+ line
54
+ for line in original.splitlines(keepends=True)
55
+ if "UV_EXCLUDE_NEWER" not in line
56
+ ]
57
+ new = "".join(kept)
58
+ if new == original:
59
+ return False
60
+ profile.with_name(profile.name + ".bak").write_text(original)
61
+ profile.write_text(new)
62
+ return True
63
+
64
+
65
+ def unset_bunfig_min_age(path: Path) -> bool:
66
+ if not path.exists():
67
+ return False
68
+ data = _load(path)
69
+ install = data.get("install", {})
70
+ if "minimumReleaseAge" not in install:
71
+ return False
72
+ del install["minimumReleaseAge"]
73
+ if not install:
74
+ del data["install"]
75
+ if not data:
76
+ path.unlink()
77
+ else:
78
+ _dump(path, data)
79
+ return True
80
+
81
+
82
+ def unset_uv_exclude_newer(path: Path) -> bool:
83
+ if not path.exists():
84
+ return False
85
+ data = _load(path)
86
+ if "exclude-newer" not in data:
87
+ return False
88
+ del data["exclude-newer"]
89
+ if not data:
90
+ path.unlink()
91
+ else:
92
+ _dump(path, data)
93
+ return True
94
+
95
+
96
+ def remove_deno_alias(profile: Path) -> str:
97
+ """Remove gestate's deno alias if present. Returns 'removed', 'absent', or 'foreign'."""
98
+ if not profile.exists():
99
+ return "absent"
100
+ text = profile.read_text()
101
+ line_matcher = re.compile(
102
+ r"^alias deno='command deno --minimum-dependency-age=[^']+'$"
103
+ )
104
+ lines = text.splitlines(keepends=True)
105
+ kept = [line for line in lines if not line_matcher.match(line.rstrip("\n"))]
106
+ if len(kept) == len(lines):
107
+ return "foreign" if any("alias deno=" in line for line in lines) else "absent"
108
+ profile.with_name(profile.name + ".bak").write_text(text)
109
+ profile.write_text("".join(kept))
110
+ return "removed"
111
+
112
+
113
+ def set_deno_alias(profile: Path, age: str) -> str:
114
+ """Ensure deno alias enforces --minimum-dependency-age=<age>.
115
+
116
+ Returns 'added' (no prior alias), 'present' (already at target), 'updated'
117
+ (overwrote our prior alias with a different age), or 'conflict' (refused to
118
+ clobber a user-authored deno alias of a different shape).
119
+ """
120
+ line = f"alias deno='command deno --minimum-dependency-age={age}'"
121
+ existing = profile.read_text() if profile.exists() else ""
122
+ if line in existing:
123
+ return "present"
124
+ if _DENO_OUR_LINE.search(existing):
125
+ profile.with_name(profile.name + ".bak").write_text(existing)
126
+ profile.write_text(_DENO_OUR_LINE.sub(line, existing))
127
+ return "updated"
128
+ if "alias deno=" in existing:
129
+ return "conflict"
130
+ profile.parent.mkdir(parents=True, exist_ok=True)
131
+ if profile.exists():
132
+ profile.with_name(profile.name + ".bak").write_text(existing)
133
+ sep = "" if not existing or existing.endswith("\n") else "\n"
134
+ profile.write_text(existing + sep + line + "\n")
135
+ return "added"
gestate/detect.py ADDED
@@ -0,0 +1,60 @@
1
+ """Locate installed package managers and their config destinations."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ def have(cmd: str) -> bool:
10
+ return shutil.which(cmd) is not None
11
+
12
+
13
+ def yarn_major() -> int | None:
14
+ if not have("yarn"):
15
+ return None
16
+ try:
17
+ result = subprocess.run(
18
+ ["yarn", "--version"], capture_output=True, text=True, check=True, timeout=5
19
+ )
20
+ except (
21
+ subprocess.CalledProcessError,
22
+ FileNotFoundError,
23
+ subprocess.TimeoutExpired,
24
+ ):
25
+ return None
26
+ try:
27
+ return int(result.stdout.strip().split(".")[0])
28
+ except (ValueError, IndexError):
29
+ return None
30
+
31
+
32
+ def detect_tools() -> dict[str, bool]:
33
+ return {
34
+ "npm": have("npm"),
35
+ "pnpm": have("pnpm"),
36
+ "yarn": have("yarn"),
37
+ "bun": have("bun") or (Path.home() / ".bun" / "bin" / "bun").exists(),
38
+ "deno": have("deno"),
39
+ "pip": have("pip") or have("pip3"),
40
+ "uv": have("uv"),
41
+ }
42
+
43
+
44
+ def detect_profile() -> Path:
45
+ shell = os.environ.get("SHELL", "")
46
+ home = Path.home()
47
+ if "zsh" in shell:
48
+ return home / ".zshrc"
49
+ if "bash" in shell:
50
+ return home / ".bashrc"
51
+ return home / ".profile"
52
+
53
+
54
+ def uv_config_path() -> Path:
55
+ base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
56
+ return base / "uv" / "uv.toml"
57
+
58
+
59
+ def bunfig_path() -> Path:
60
+ return Path.home() / ".bunfig.toml"
gestate/status.py ADDED
@@ -0,0 +1,68 @@
1
+ """Read current release-age config for each supported tool."""
2
+
3
+ import subprocess
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+
8
+ def _cli_get(cmd: list[str]) -> str | None:
9
+ try:
10
+ result = subprocess.run(
11
+ cmd, capture_output=True, text=True, check=True, timeout=10
12
+ )
13
+ except (
14
+ subprocess.CalledProcessError,
15
+ FileNotFoundError,
16
+ subprocess.TimeoutExpired,
17
+ ):
18
+ return None
19
+ value = result.stdout.strip()
20
+ if not value or value in {"undefined", "null"}:
21
+ return None
22
+ return value
23
+
24
+
25
+ def npm_current() -> str | None:
26
+ return _cli_get(["npm", "config", "get", "min-release-age", "--location=user"])
27
+
28
+
29
+ def pnpm_current() -> str | None:
30
+ return _cli_get(["pnpm", "config", "get", "minimumReleaseAge", "--global"])
31
+
32
+
33
+ def yarn_current() -> str | None:
34
+ return _cli_get(["yarn", "config", "get", "--home", "npmMinimalAgeGate"])
35
+
36
+
37
+ def pip_current() -> str | None:
38
+ return _cli_get(["pip", "config", "get", "global.uploaded-prior-to"]) or _cli_get(
39
+ ["pip3", "config", "get", "global.uploaded-prior-to"]
40
+ )
41
+
42
+
43
+ def bun_current(path: Path) -> str | None:
44
+ if not path.exists():
45
+ return None
46
+ with path.open("rb") as f:
47
+ data = tomllib.load(f)
48
+ val = data.get("install", {}).get("minimumReleaseAge")
49
+ return None if val is None else str(val)
50
+
51
+
52
+ def uv_current(path: Path) -> str | None:
53
+ if not path.exists():
54
+ return None
55
+ with path.open("rb") as f:
56
+ data = tomllib.load(f)
57
+ val = data.get("exclude-newer")
58
+ return None if val is None else str(val)
59
+
60
+
61
+ def deno_current_alias(profile: Path) -> str | None:
62
+ """Return the existing `alias deno=…` line if any, else None."""
63
+ if not profile.exists():
64
+ return None
65
+ for line in profile.read_text().splitlines():
66
+ if line.startswith("alias deno="):
67
+ return line
68
+ return None
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: gestate
3
+ Version: 1.0.0
4
+ Summary: Set a minimum release age on local package managers to defend against zero-day supply chain attacks.
5
+ Author-email: Lincoln Loop <chris@lincolnloop.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: bun,cooldown,deno,npm,pip,pnpm,security,supply-chain,uv,yarn
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Topic :: Security
16
+ Classifier: Topic :: Software Development :: Build Tools
17
+ Classifier: Topic :: System :: Installation/Setup
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: rich>=13
20
+ Requires-Dist: tomli-w>=1.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # gestate
24
+
25
+ Set a minimum release age on local package managers so installs ignore versions younger than `N` days. Most malicious packages are caught and yanked within days of publishing; refusing fresh versions blocks the bulk of supply-chain attacks.
26
+
27
+ ## Run
28
+
29
+ ```
30
+ uvx gestate # interactive
31
+ uvx gestate set 3 # 3-day minimum, installed tools only
32
+ uvx gestate set 3 --all # also pre-configure file-based tools (bun, deno, uv)
33
+ uvx gestate revert # remove gestate's settings
34
+ uvx gestate explain bun # show how one tool's setting is stored
35
+ ```
36
+
37
+ Interactive mode always shows a plan and asks for explicit confirmation before touching anything. The subcommands skip the confirm — meant for scripts, not your daily shell. Running with no subcommand outside a TTY exits with an error.
38
+
39
+ Plain text output is used when stdout isn't a terminal (no Rich tables/colors).
40
+
41
+ ## What it sets
42
+
43
+ | Tool | Where | Key (unit) |
44
+ |---|---|---|
45
+ | npm | `~/.npmrc` | `min-release-age` (days) |
46
+ | pnpm | global pnpm config | `minimumReleaseAge` (minutes) |
47
+ | yarn | `~/.yarnrc.yml` (4.10+) | `npmMinimalAgeGate` (minutes) |
48
+ | bun | `~/.bunfig.toml` | `[install] minimumReleaseAge` (seconds) |
49
+ | deno | shell profile | `alias deno='command deno --minimum-dependency-age=P<N>D'` |
50
+ | pip | user pip config | `global.uploaded-prior-to` (`P<N>D`) |
51
+ | uv | `~/.config/uv/uv.toml` | `exclude-newer` (`"N days"`) |
52
+
53
+ `gestate explain <tool>` prints that tool's current value and the exact mechanism `set` / `revert` use.
54
+
55
+ Scope:
56
+ - default — only configure installed tools
57
+ - `--all` — also pre-write config files for `bun`, `deno`, `uv` even if they aren't installed yet
58
+
59
+ ## Revert
60
+
61
+ `uvx gestate revert` removes everything gestate set:
62
+
63
+ - CLI tools — `config delete` / `config unset`
64
+ - `bun` / `uv` — remove the key; delete the file if it was the only key
65
+ - `deno` — remove our alias line; leave foreign `alias deno=` lines alone
66
+
67
+ Backups (`.bak`) are written next to any edited shell-profile or TOML file.
68
+
69
+ ## Allowlists
70
+
71
+ If you publish packages of your own and want them exempt from the delay, see [docs/allowlists.md](docs/allowlists.md). Most managers support an exclude list; npm and pip don't yet.
72
+
73
+ ## Caveats
74
+
75
+ - **yarn**: 4.10+ only. Older yarn is detected and skipped.
76
+ - **deno**: no global config exists; the shell alias only covers interactive shells. For CI, pass `--minimum-dependency-age=P<N>D` to `deno install`/`deno update`, or commit a project `deno.json` with `"minimumDependencyAge": "P<N>D"`.
77
+ - **npm exclude**: tracked in [npm/cli#8994](https://github.com/npm/cli/issues/8994).
78
+ - **pip exclude**: none — `global.uploaded-prior-to` is global only.
79
+
80
+ For PR-creation guards (Dependabot, Renovate) and adjacent layers, see [docs/complementary.md](docs/complementary.md).
81
+
82
+ ## Development
83
+
84
+ ```
85
+ uv sync
86
+ uv run pytest
87
+ ```
88
+
89
+ Requires Python 3.11+, macOS or Linux.
@@ -0,0 +1,11 @@
1
+ gestate/__init__.py,sha256=uP_rMF2ZwuDbsqgyIckv1XFW8JWOzff5LhPOLZtyzsw,84
2
+ gestate/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ gestate/cli.py,sha256=4p2aW_kJmdR4FTQt01PIL9QhbcRSFwtKwp9EeQ4PZNA,22211
4
+ gestate/config.py,sha256=K8pPb4CBkXvXuU3nY2pcxpQcu72zp6Hh62YjC1osZPs,4069
5
+ gestate/detect.py,sha256=bor6vuWIkNAcOPKOruJ6jh_yB38u1ztSEp_8QCs8_jQ,1471
6
+ gestate/status.py,sha256=Bn4jFnPJGC48Lx_zzbI3ZTwy-NBeSoLm9jZDOJz8rn8,1893
7
+ gestate-1.0.0.dist-info/METADATA,sha256=2RQ52ngPsJzNZecbEyDsLjRNz1mGxAAVj-ahf0FmDsk,3801
8
+ gestate-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ gestate-1.0.0.dist-info/entry_points.txt,sha256=S9-YSwV4TOvElMdL7q-0ihe3Ice43ny-fGFkvFMS98U,45
10
+ gestate-1.0.0.dist-info/licenses/LICENSE,sha256=edx5WcdHn3GVcmqqARstuhSXu0FPqx7qBqDFc2YgbMY,1069
11
+ gestate-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gestate = gestate.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lincoln Loop
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.