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 +3 -0
- gestate/__main__.py +3 -0
- gestate/cli.py +713 -0
- gestate/config.py +135 -0
- gestate/detect.py +60 -0
- gestate/status.py +68 -0
- gestate-1.0.0.dist-info/METADATA +89 -0
- gestate-1.0.0.dist-info/RECORD +11 -0
- gestate-1.0.0.dist-info/WHEEL +4 -0
- gestate-1.0.0.dist-info/entry_points.txt +2 -0
- gestate-1.0.0.dist-info/licenses/LICENSE +21 -0
gestate/__init__.py
ADDED
gestate/__main__.py
ADDED
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,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.
|