azup-cli 2.7.1__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.
azup.py
ADDED
|
@@ -0,0 +1,2806 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""azup - install, update and check your Azure engineering toolbox on Windows, macOS and Linux.
|
|
3
|
+
|
|
4
|
+
azup interactive menu
|
|
5
|
+
azup select checklist: tick what you want, azup installs/updates it
|
|
6
|
+
azup status what is installed / outdated
|
|
7
|
+
azup install [...] install tools (names, --profile core|full, --all; no args = checklist)
|
|
8
|
+
azup update [...] update tools (default: everything outdated)
|
|
9
|
+
azup doctor sign-in + health checks
|
|
10
|
+
azup changes [name] release notes for what's outdated (or the named tools)
|
|
11
|
+
azup nudge [enable] one-line "3 tools outdated" reminder when you open a terminal
|
|
12
|
+
azup schedule enable update automatically (weekly/daily) via launchd / systemd / Task Scheduler
|
|
13
|
+
azup whoami who am I signed in as, in az / azd / Az PowerShell / Graph?
|
|
14
|
+
azup ctx [name] switch subscription everywhere at once (type-to-filter picker)
|
|
15
|
+
azup login [target] az | azd | ps | graph | all
|
|
16
|
+
azup self-update update azup itself
|
|
17
|
+
azup joke Azzy tells a joke (animations: --no-anim or AZUP_NO_ANIM=1 to disable)
|
|
18
|
+
|
|
19
|
+
Backends: Homebrew (macOS / Linux), winget (Windows), Microsoft's tarball (PowerShell on Linux, no sudo),
|
|
20
|
+
PowerShellGet (PowerShell modules) and `az extension` / `az bicep`.
|
|
21
|
+
Python 3.8+, standard library only.
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import concurrent.futures as cf
|
|
25
|
+
import glob
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import platform
|
|
29
|
+
import plistlib
|
|
30
|
+
import os
|
|
31
|
+
import random
|
|
32
|
+
import re
|
|
33
|
+
import select
|
|
34
|
+
import shlex
|
|
35
|
+
import shutil
|
|
36
|
+
import subprocess
|
|
37
|
+
import sys
|
|
38
|
+
import tarfile
|
|
39
|
+
import tempfile
|
|
40
|
+
import textwrap
|
|
41
|
+
import threading
|
|
42
|
+
import time
|
|
43
|
+
import urllib.request
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import termios
|
|
49
|
+
import tty
|
|
50
|
+
except ImportError: # Windows
|
|
51
|
+
termios = tty = None
|
|
52
|
+
try:
|
|
53
|
+
import msvcrt
|
|
54
|
+
except ImportError:
|
|
55
|
+
msvcrt = None
|
|
56
|
+
|
|
57
|
+
VERSION = "2.7.1"
|
|
58
|
+
REPO = os.environ.get("AZUP_REPO", "saeid-adz/azup") # set once after publishing
|
|
59
|
+
BRANCH = os.environ.get("AZUP_BRANCH", "main")
|
|
60
|
+
|
|
61
|
+
IS_WIN = os.name == "nt"
|
|
62
|
+
IS_MAC = sys.platform == "darwin"
|
|
63
|
+
IS_LINUX = sys.platform.startswith("linux")
|
|
64
|
+
OS_NAME = "Windows" if IS_WIN else "macOS" if IS_MAC else "Linux"
|
|
65
|
+
OS_KEY = "win" if IS_WIN else "mac" if IS_MAC else "linux"
|
|
66
|
+
|
|
67
|
+
if IS_WIN:
|
|
68
|
+
os.system("") # enables ANSI escape processing in the Windows console
|
|
69
|
+
try:
|
|
70
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
TTY = sys.stdout.isatty()
|
|
75
|
+
COLOR = TTY and "NO_COLOR" not in os.environ
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ───────────────────────────── styling ─────────────────────────────
|
|
79
|
+
def _c(code):
|
|
80
|
+
return (lambda s: f"\x1b[{code}m{s}\x1b[0m") if COLOR else (lambda s: s)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
bold, dim, red, green, yellow, cyan = _c("1"), _c("2"), _c("31"), _c("32"), _c("33"), _c("36")
|
|
84
|
+
azure = _c("38;5;39")
|
|
85
|
+
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def plain_len(s):
|
|
89
|
+
return len(ANSI.sub("", s))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def pad(s, w):
|
|
93
|
+
return s + " " * max(0, w - plain_len(s))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ───────────────────────────── animation ─────────────────────────────
|
|
97
|
+
# Opt out with AZUP_NO_ANIM=1, --no-anim, NO_COLOR, or by piping the output.
|
|
98
|
+
ANIM = TTY and not os.environ.get("AZUP_NO_ANIM") and "--no-anim" not in sys.argv
|
|
99
|
+
TRUECOLOR = COLOR and (
|
|
100
|
+
os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit")
|
|
101
|
+
or "WT_SESSION" in os.environ
|
|
102
|
+
or os.environ.get("TERM_PROGRAM") in ("iTerm.app", "vscode", "WezTerm", "ghostty"))
|
|
103
|
+
|
|
104
|
+
ART = [
|
|
105
|
+
" ___ _____ _ _ ____ ",
|
|
106
|
+
" / _ \\ |__ / | | | | _ \\ ",
|
|
107
|
+
" / /_\\ \\ / / | | | | |_) |",
|
|
108
|
+
"/ _____ \\/ /_ | |_| | __/ ",
|
|
109
|
+
"\\_/ \\_/____| \\___/|_| ",
|
|
110
|
+
]
|
|
111
|
+
STOPS = [(0, 90, 200), (0, 150, 235), (80, 220, 255)] # Azure blues
|
|
112
|
+
CONFETTI = [(242, 80, 34), (127, 186, 0), (0, 164, 239), (255, 185, 0), (180, 110, 255)]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def rgb(r, g, b, s):
|
|
116
|
+
return f"\x1b[38;2;{r};{g};{b}m{s}\x1b[0m" if TRUECOLOR else azure(s)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def lerp(a, b, t):
|
|
120
|
+
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def grad(t):
|
|
124
|
+
t = min(1.0, max(0.0, t))
|
|
125
|
+
return lerp(STOPS[0], STOPS[1], t * 2) if t < .5 else lerp(STOPS[1], STOPS[2], (t - .5) * 2)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def art_frame(pos):
|
|
129
|
+
"""The banner as 5 coloured lines, with a white shine centred on column `pos`."""
|
|
130
|
+
w = max(len(l) for l in ART)
|
|
131
|
+
out = []
|
|
132
|
+
for line in ART:
|
|
133
|
+
if not TRUECOLOR:
|
|
134
|
+
out.append(azure(line))
|
|
135
|
+
continue
|
|
136
|
+
s = ""
|
|
137
|
+
for x, ch in enumerate(line):
|
|
138
|
+
if ch == " ":
|
|
139
|
+
s += " "
|
|
140
|
+
continue
|
|
141
|
+
k = max(0.0, 1 - abs(x - pos) / 5) ** 2 * 0.85
|
|
142
|
+
s += rgb(*lerp(grad(x / w), (255, 255, 255), k), ch)
|
|
143
|
+
out.append(s)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def banner(animate=False):
|
|
148
|
+
print()
|
|
149
|
+
animate = animate and ANIM
|
|
150
|
+
if animate and TRUECOLOR:
|
|
151
|
+
w = max(len(l) for l in ART)
|
|
152
|
+
for n, pos in enumerate(range(-6, w + 10, 2)):
|
|
153
|
+
if n:
|
|
154
|
+
sys.stdout.write(f"\x1b[{len(ART)}A")
|
|
155
|
+
sys.stdout.write("\n".join(" " + l for l in art_frame(pos)) + "\n")
|
|
156
|
+
sys.stdout.flush()
|
|
157
|
+
time.sleep(0.025)
|
|
158
|
+
else:
|
|
159
|
+
for l in art_frame(-99):
|
|
160
|
+
print(" " + l)
|
|
161
|
+
tag = f"Azure toolbox manager · v{VERSION} · {OS_NAME}"
|
|
162
|
+
if animate:
|
|
163
|
+
sys.stdout.write(" ")
|
|
164
|
+
for ch in tag:
|
|
165
|
+
sys.stdout.write(dim(ch))
|
|
166
|
+
sys.stdout.flush()
|
|
167
|
+
time.sleep(0.008)
|
|
168
|
+
print()
|
|
169
|
+
else:
|
|
170
|
+
print(" " + dim(tag))
|
|
171
|
+
print()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── mascot ("Azzy", an original little cloud-bot) ──
|
|
175
|
+
JOKES = [
|
|
176
|
+
"It works on my subscription.",
|
|
177
|
+
"Tried turning the tenant off and on again?",
|
|
178
|
+
"Naming things: still the hard part.",
|
|
179
|
+
"Please don't `az group delete` on a Friday.",
|
|
180
|
+
"I read your Bicep. It's lovely. (I'm a bot.)",
|
|
181
|
+
"Tags are a love language. Tag your stuff.",
|
|
182
|
+
"Tip: 'u' in the checklist ticks outdated.",
|
|
183
|
+
"Tip: `azup doctor` checks all your sign-ins.",
|
|
184
|
+
"Fun fact: Graph 'beta' is a lifestyle.",
|
|
185
|
+
"99 bugs in the code. Patch one: 117 bugs.",
|
|
186
|
+
"Localhost: the only region with 100% uptime.",
|
|
187
|
+
"Reticulating Azure splines…",
|
|
188
|
+
"Counting your subscriptions. Is that… 14?",
|
|
189
|
+
"Teaching the cloud to behave…",
|
|
190
|
+
]
|
|
191
|
+
CHEERS = ["All done! Your toolbox is looking sharp.",
|
|
192
|
+
"Done. That was smooth. Suspiciously smooth.",
|
|
193
|
+
"Everything is shipshape. No resource groups were harmed.",
|
|
194
|
+
"Fresh bits installed. Go deploy something!"]
|
|
195
|
+
OOPS = ["Something went wrong. The log above knows more than I do.",
|
|
196
|
+
"Oops. Scroll up – the error is shy, but it's there."]
|
|
197
|
+
BUBBLE_W = 46
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Azzy is an "A" (a nod to the Azure shape, drawn from scratch): light left half, dark right half.
|
|
201
|
+
# 'e' = eye cell, 'm' = mouth cell.
|
|
202
|
+
MASCOT = [" ▟███▙ ",
|
|
203
|
+
" ▟█████▙ ",
|
|
204
|
+
" ▟█e███e█▙ ",
|
|
205
|
+
" ▟████m████▙ ",
|
|
206
|
+
"▟██▘ ▝██▙"]
|
|
207
|
+
MASCOT_W = 13
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def mascot_rows(eyes="◕ ◕", mouth="‿"):
|
|
211
|
+
out = []
|
|
212
|
+
for row in MASCOT:
|
|
213
|
+
s, seen = "", 0
|
|
214
|
+
for x, ch in enumerate(row):
|
|
215
|
+
fg, bg = ("38;5;81", "48;5;81") if x < MASCOT_W // 2 else ("38;5;33", "48;5;33")
|
|
216
|
+
if ch == " ":
|
|
217
|
+
s += " "
|
|
218
|
+
elif ch in "em":
|
|
219
|
+
g = mouth if ch == "m" else (eyes[0] if not seen else eyes[-1])
|
|
220
|
+
seen += ch == "e"
|
|
221
|
+
s += f"\x1b[97;{bg}m{g}\x1b[0m" if COLOR else g
|
|
222
|
+
else:
|
|
223
|
+
s += f"\x1b[{fg}m{ch}\x1b[0m" if COLOR else ch
|
|
224
|
+
out.append(s)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def mascot_block(lines, eyes="◕ ◕", mouth="‿"):
|
|
229
|
+
"""Mascot on the left, speech bubble on the right. Returns a list of strings."""
|
|
230
|
+
w = max(plain_len(l) for l in lines)
|
|
231
|
+
body = [f"│ {pad(l, w)} │" for l in lines]
|
|
232
|
+
bub = ["╭" + "─" * (w + 2) + "╮"] + body + ["╰" + "─" * (w + 2) + "╯"]
|
|
233
|
+
m = mascot_rows(eyes, mouth)
|
|
234
|
+
start = max(0, (len(m) - len(bub)) // 2)
|
|
235
|
+
mid = start + len(bub) // 2
|
|
236
|
+
rows = []
|
|
237
|
+
for r in range(max(len(m), len(bub) + start)):
|
|
238
|
+
left = m[r] if r < len(m) else " " * MASCOT_W
|
|
239
|
+
j = r - start
|
|
240
|
+
right = dim(bub[j]) if 0 <= j < len(bub) else ""
|
|
241
|
+
tail = azure("◂ ") if r == mid else " "
|
|
242
|
+
rows.append(" " + left + tail + right)
|
|
243
|
+
return rows
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def say(msg, mood="happy"):
|
|
247
|
+
eyes, mouth = {"happy": ("◕ ◕", "‿"), "sad": ("╥ ╥", "∩"), "think": ("◔ ◔", "─")}[mood]
|
|
248
|
+
lines = textwrap.wrap(msg, BUBBLE_W) or [""]
|
|
249
|
+
print("\n".join(mascot_block(lines, eyes, mouth)))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def confetti(width=46):
|
|
253
|
+
if not ANIM:
|
|
254
|
+
return
|
|
255
|
+
syms = "✦✧·•*+"
|
|
256
|
+
for _ in range(12):
|
|
257
|
+
line = "".join(rgb(*random.choice(CONFETTI), random.choice(syms)) if random.random() < .35
|
|
258
|
+
else " " for _ in range(width))
|
|
259
|
+
sys.stdout.write("\r " + line + "\x1b[K")
|
|
260
|
+
sys.stdout.flush()
|
|
261
|
+
time.sleep(0.06)
|
|
262
|
+
sys.stdout.write("\r\x1b[K")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def progress(i, n, width=18):
|
|
266
|
+
filled = int(width * i / max(n, 1))
|
|
267
|
+
cells = "".join(rgb(*grad(x / width), "█") if x < filled else dim("░") for x in range(width))
|
|
268
|
+
return f"▕{cells}▏"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class Spinner:
|
|
272
|
+
"""Plain spinner, or (mascot=True) Azzy with blinking eyes who tells jokes if you wait."""
|
|
273
|
+
FR = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
274
|
+
EYES = ["◕ ◕"] * 8 + ["─ ─"] + ["◕ ◕"] * 5 + ["◔ ◔"] * 3 + ["◑ ◑"] * 3 + ["◕ ◕"] * 4
|
|
275
|
+
H = 5
|
|
276
|
+
|
|
277
|
+
def __init__(self, msg, mascot=False):
|
|
278
|
+
self.msg, self._stop = msg, threading.Event()
|
|
279
|
+
self.mascot = mascot and ANIM and shutil.get_terminal_size().columns >= 72
|
|
280
|
+
self.t = threading.Thread(target=self._run, daemon=True)
|
|
281
|
+
|
|
282
|
+
def _run(self):
|
|
283
|
+
i, t0, seed = 0, time.time(), random.randrange(len(JOKES))
|
|
284
|
+
while not self._stop.is_set():
|
|
285
|
+
if self.mascot:
|
|
286
|
+
el = time.time() - t0
|
|
287
|
+
text = self.msg if el < 2.5 else JOKES[(seed + int((el - 2.5) // 3.5)) % len(JOKES)]
|
|
288
|
+
text = pad(f"{cyan(self.FR[i % 10])} {text}", BUBBLE_W)
|
|
289
|
+
rows = mascot_block([text], self.EYES[(i // 2) % len(self.EYES)], "o")
|
|
290
|
+
if i:
|
|
291
|
+
sys.stdout.write(f"\x1b[{self.H}A")
|
|
292
|
+
sys.stdout.write("".join("\x1b[K" + r + "\n" for r in rows))
|
|
293
|
+
else:
|
|
294
|
+
sys.stdout.write(f"\r {cyan(self.FR[i % 10])} {self.msg}\x1b[K")
|
|
295
|
+
sys.stdout.flush()
|
|
296
|
+
i += 1
|
|
297
|
+
time.sleep(0.08)
|
|
298
|
+
|
|
299
|
+
def __enter__(self):
|
|
300
|
+
if TTY:
|
|
301
|
+
sys.stdout.write("\x1b[?25l")
|
|
302
|
+
self.t.start()
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
def __exit__(self, *a):
|
|
306
|
+
if TTY:
|
|
307
|
+
self._stop.set()
|
|
308
|
+
self.t.join()
|
|
309
|
+
sys.stdout.write(f"\x1b[{self.H}A\x1b[J" if self.mascot else "\r\x1b[K")
|
|
310
|
+
sys.stdout.write("\x1b[?25h")
|
|
311
|
+
sys.stdout.flush()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ───────────────────────────── catalog ─────────────────────────────
|
|
316
|
+
@dataclass
|
|
317
|
+
class Tool:
|
|
318
|
+
key: str
|
|
319
|
+
name: str
|
|
320
|
+
group: str
|
|
321
|
+
kind: str # pkg | psmod | azext | bicep
|
|
322
|
+
desc: str = ""
|
|
323
|
+
core: bool = False
|
|
324
|
+
brew: str = "" # Homebrew formula (macOS + Linux)
|
|
325
|
+
cask: str = "" # Homebrew cask (macOS only, preferred over formula there)
|
|
326
|
+
winget: str = "" # winget package id (Windows)
|
|
327
|
+
snap: str = "" # snap (Linux fallback when there is no brew formula)
|
|
328
|
+
ghtar: str = "" # GitHub repo whose release tarball is installed to ~/.local (Linux, no sudo)
|
|
329
|
+
cmd: str = "" # binary name, used to spot installs made outside azup
|
|
330
|
+
vcmd: Tuple[str, ...] = ()
|
|
331
|
+
pkg: str = "" # module / extension name for psmod / azext / azdext
|
|
332
|
+
ai: bool = False # part of `--profile ai`
|
|
333
|
+
platforms: Tuple[str, ...] = () # limit a pkg to these OSes: "win", "mac", "linux"
|
|
334
|
+
min_azd: str = "" # azd extensions: minimum azd version
|
|
335
|
+
repo: str = "" # GitHub "owner/name" that publishes release notes
|
|
336
|
+
tag_re: str = "" # only releases whose tag matches (for repos that mix several products)
|
|
337
|
+
notes_url: str = "" # markdown release-notes page with "## <date>\\n\\nVersion x.y.z" sections
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def short(self):
|
|
341
|
+
return (self.brew or self.cask).split("/")[-1]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
CATALOG: List[Tool] = [
|
|
345
|
+
Tool("az", "Azure CLI", "CLI", "pkg", "az command line", True,
|
|
346
|
+
brew="azure-cli", winget="Microsoft.AzureCLI", cmd="az", repo="Azure/azure-cli", tag_re=r"^azure-cli-\d", notes_url="https://raw.githubusercontent.com/MicrosoftDocs/azure-docs-cli/main/docs-ref-conceptual/Latest-version/release-notes-azure-cli.md"),
|
|
347
|
+
Tool("azd", "Azure Developer CLI", "CLI", "pkg", "azd up / templates", True,
|
|
348
|
+
brew="azd", winget="Microsoft.Azd", cmd="azd", vcmd=("azd", "version"), repo="Azure/azure-dev", tag_re=r"^azure-dev-cli_"),
|
|
349
|
+
Tool("bicep", "Bicep", "CLI", "bicep", "IaC language (via az bicep)", True, repo="Azure/bicep"),
|
|
350
|
+
Tool("func", "Functions Core Tools", "CLI", "pkg", "local Azure Functions",
|
|
351
|
+
brew="azure/functions/azure-functions-core-tools@4",
|
|
352
|
+
winget="Microsoft.Azure.FunctionsCoreTools", cmd="func", repo="Azure/azure-functions-core-tools"),
|
|
353
|
+
Tool("kubelogin", "kubelogin", "CLI", "pkg", "Entra auth for AKS",
|
|
354
|
+
brew="azure/kubelogin/kubelogin", winget="Microsoft.Azure.Kubelogin", cmd="kubelogin", repo="Azure/kubelogin"),
|
|
355
|
+
Tool("kubectl", "kubectl", "CLI", "pkg", "Kubernetes CLI",
|
|
356
|
+
brew="kubernetes-cli", winget="Kubernetes.kubectl", cmd="kubectl",
|
|
357
|
+
vcmd=("kubectl", "version", "--client"), repo="kubernetes/kubernetes"),
|
|
358
|
+
Tool("azcopy", "AzCopy", "CLI", "pkg", "fast blob/file transfer",
|
|
359
|
+
brew="azcopy", winget="Microsoft.Azure.AZCopy.10", cmd="azcopy", repo="Azure/azure-storage-azcopy"),
|
|
360
|
+
Tool("sqlcmd", "sqlcmd (go)", "CLI", "pkg", "Azure SQL shell",
|
|
361
|
+
brew="sqlcmd", winget="Microsoft.Sqlcmd", cmd="sqlcmd", repo="microsoft/go-sqlcmd"),
|
|
362
|
+
Tool("terraform", "Terraform", "CLI", "pkg", "IaC (azurerm)",
|
|
363
|
+
brew="hashicorp/tap/terraform", winget="Hashicorp.Terraform", cmd="terraform", repo="hashicorp/terraform"),
|
|
364
|
+
Tool("pwsh", "PowerShell 7", "PowerShell", "pkg", "needed for the modules below", True,
|
|
365
|
+
cask="powershell", winget="Microsoft.PowerShell", ghtar="PowerShell/PowerShell", cmd="pwsh", repo="PowerShell/PowerShell"),
|
|
366
|
+
Tool("Az", "Az PowerShell", "PowerShell", "psmod", "Azure PowerShell module", True, pkg="Az", repo="Azure/azure-powershell"),
|
|
367
|
+
Tool("Microsoft.Graph", "Microsoft Graph SDK", "PowerShell", "psmod", "Graph API v1.0 cmdlets",
|
|
368
|
+
True, pkg="Microsoft.Graph", repo="microsoftgraph/msgraph-sdk-powershell"),
|
|
369
|
+
Tool("Microsoft.Graph.Beta", "Microsoft Graph Beta", "PowerShell", "psmod",
|
|
370
|
+
"Graph API beta cmdlets", pkg="Microsoft.Graph.Beta", repo="microsoftgraph/msgraph-sdk-powershell"),
|
|
371
|
+
Tool("Microsoft.Entra", "Microsoft Entra PS", "PowerShell", "psmod", "Entra ID admin cmdlets",
|
|
372
|
+
pkg="Microsoft.Entra", repo="microsoftgraph/entra-powershell"),
|
|
373
|
+
Tool("ext-graph", "ext: resource-graph", "az extensions", "azext",
|
|
374
|
+
"Azure Resource Graph queries", True, pkg="resource-graph"),
|
|
375
|
+
Tool("ext-devops", "ext: azure-devops", "az extensions", "azext", "Azure DevOps from az",
|
|
376
|
+
pkg="azure-devops"),
|
|
377
|
+
Tool("ext-aks", "ext: aks-preview", "az extensions", "azext", "AKS preview features",
|
|
378
|
+
pkg="aks-preview"),
|
|
379
|
+
# ── Microsoft Foundry / AI ──
|
|
380
|
+
Tool("foundry-local", "Foundry Local", "AI", "pkg", "run models on your machine (preview)",
|
|
381
|
+
ai=True, brew="microsoft/foundrylocal/foundrylocal", winget="Microsoft.FoundryLocal",
|
|
382
|
+
cmd="foundry", platforms=("win", "mac"), repo="microsoft/Foundry-Local"),
|
|
383
|
+
Tool("azd-foundry", "azd: Microsoft Foundry", "AI", "azdext", "Foundry bundle for azd (agents, models…)",
|
|
384
|
+
ai=True, pkg="microsoft.foundry", min_azd="1.37.0", repo="Azure/azure-dev", tag_re=r"^azd-ext-microsoft-foundry_"),
|
|
385
|
+
Tool("azd-agents", "azd: AI agents", "AI", "azdext", "scaffold / deploy hosted agents",
|
|
386
|
+
pkg="azure.ai.agents", min_azd="1.25.0", repo="Azure/azure-dev", tag_re=r"^azd-ext-azure-ai-agents_"),
|
|
387
|
+
Tool("ext-ml", "ext: ml (Azure ML)", "AI", "azext", "Azure ML v2 + Foundry hubs for az",
|
|
388
|
+
ai=True, pkg="ml"),
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def spec(t: Tool) -> Optional[Tuple[str, str]]:
|
|
393
|
+
"""(manager, package-id) for a 'pkg' tool on this OS, else None."""
|
|
394
|
+
if t.kind != "pkg":
|
|
395
|
+
return None
|
|
396
|
+
if t.platforms and OS_KEY not in t.platforms:
|
|
397
|
+
return None
|
|
398
|
+
if IS_WIN:
|
|
399
|
+
return ("winget", t.winget) if t.winget else None
|
|
400
|
+
if IS_MAC:
|
|
401
|
+
if t.cask:
|
|
402
|
+
return ("cask", t.cask)
|
|
403
|
+
return ("brew", t.brew) if t.brew else None
|
|
404
|
+
if t.brew:
|
|
405
|
+
return ("brew", t.brew)
|
|
406
|
+
if t.ghtar:
|
|
407
|
+
return ("tarball", t.ghtar)
|
|
408
|
+
return ("snap", t.snap) if t.snap else None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
TOOLS = [t for t in CATALOG if t.kind != "pkg" or spec(t)]
|
|
412
|
+
BY_KEY = {}
|
|
413
|
+
for _t in TOOLS:
|
|
414
|
+
BY_KEY.setdefault(_t.key.lower(), _t) # case-insensitive; first in the catalog wins ("az" = Azure CLI)
|
|
415
|
+
ALIASES = {"azps": "Az", "az-ps": "Az", "az-powershell": "Az", "azure-cli": "az", "azcli": "az",
|
|
416
|
+
"graph": "Microsoft.Graph", "entra": "Microsoft.Entra", "functions": "func"}
|
|
417
|
+
HOST_NAME = {"brew": "Homebrew", "cask": "Homebrew", "winget": "winget", "snap": "snap"}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ───────────────────────────── process helpers ─────────────────────────────
|
|
421
|
+
def have(c):
|
|
422
|
+
return shutil.which(c) is not None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def refresh_path():
|
|
426
|
+
"""Pick up PATH changes made by installers that ran during this session."""
|
|
427
|
+
if IS_WIN:
|
|
428
|
+
try:
|
|
429
|
+
import winreg
|
|
430
|
+
parts = []
|
|
431
|
+
for hive, sub in ((winreg.HKEY_LOCAL_MACHINE,
|
|
432
|
+
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
|
|
433
|
+
(winreg.HKEY_CURRENT_USER, "Environment")):
|
|
434
|
+
try:
|
|
435
|
+
with winreg.OpenKey(hive, sub) as k:
|
|
436
|
+
parts.append(os.path.expandvars(winreg.QueryValueEx(k, "Path")[0]))
|
|
437
|
+
except OSError:
|
|
438
|
+
pass
|
|
439
|
+
wa = os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WindowsApps") # winget lives here
|
|
440
|
+
os.environ["PATH"] = os.pathsep.join(parts + [wa, os.environ.get("PATH", "")])
|
|
441
|
+
except ImportError:
|
|
442
|
+
pass
|
|
443
|
+
else:
|
|
444
|
+
for d in ("/opt/homebrew/bin", "/usr/local/bin", "/home/linuxbrew/.linuxbrew/bin",
|
|
445
|
+
os.path.expanduser("~/.linuxbrew/bin")):
|
|
446
|
+
if os.path.isdir(d) and d not in os.environ.get("PATH", "").split(":"):
|
|
447
|
+
os.environ["PATH"] += ":" + d
|
|
448
|
+
lb = os.path.expanduser("~/.local/bin")
|
|
449
|
+
if os.path.islink(os.path.join(lb, "pwsh")) and not os.environ.get("PATH", "").startswith(lb + ":"):
|
|
450
|
+
os.environ["PATH"] = lb + ":" + os.environ.get("PATH", "") # prefer azup's PowerShell
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def resolve_exe(cmd):
|
|
454
|
+
exe = shutil.which(cmd[0])
|
|
455
|
+
return [exe] + list(cmd[1:]) if exe else list(cmd)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def run(cmd, timeout=60):
|
|
459
|
+
try:
|
|
460
|
+
p = subprocess.run(resolve_exe(cmd), capture_output=True, text=True, timeout=timeout,
|
|
461
|
+
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL)
|
|
462
|
+
return p.returncode, p.stdout.strip()
|
|
463
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
464
|
+
return 1, ""
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def ps_exe():
|
|
468
|
+
"""PowerShell 7 if present, else Windows PowerShell 5.1."""
|
|
469
|
+
for c in ("pwsh", "powershell") if IS_WIN else ("pwsh",):
|
|
470
|
+
if have(c):
|
|
471
|
+
return c
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def ps_cmd(body):
|
|
476
|
+
return [ps_exe() or "pwsh", "-NoProfile", "-NonInteractive", "-Command",
|
|
477
|
+
"$ProgressPreference='SilentlyContinue';" + body]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def ps_json(script):
|
|
481
|
+
if not ps_exe():
|
|
482
|
+
return []
|
|
483
|
+
rc, out = run(ps_cmd(script), 120)
|
|
484
|
+
if rc or not out:
|
|
485
|
+
return []
|
|
486
|
+
try:
|
|
487
|
+
d = json.loads(out)
|
|
488
|
+
except ValueError:
|
|
489
|
+
return []
|
|
490
|
+
return d if isinstance(d, list) else [d]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def vtuple(v):
|
|
494
|
+
return tuple(int(x) for x in re.findall(r"\d+", v or "")[:4])
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# ───────────────────────────── detection ─────────────────────────────
|
|
498
|
+
@dataclass
|
|
499
|
+
class State:
|
|
500
|
+
installed: bool = False
|
|
501
|
+
version: str = ""
|
|
502
|
+
managed: bool = True # installed through the backend azup uses
|
|
503
|
+
latest: str = ""
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def outdated(self):
|
|
507
|
+
return bool(self.installed and self.latest and self.version
|
|
508
|
+
and vtuple(self.latest) > vtuple(self.version))
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def winget_state(pid):
|
|
512
|
+
rc, out = run(["winget", "list", "--id", pid, "-e", "--accept-source-agreements"], 60)
|
|
513
|
+
for ln in out.splitlines():
|
|
514
|
+
tk = ln.split()
|
|
515
|
+
if pid in tk:
|
|
516
|
+
j = tk.index(pid) + 1
|
|
517
|
+
while j < len(tk) and tk[j] in (">", "<", "~"):
|
|
518
|
+
j += 1
|
|
519
|
+
if j >= len(tk):
|
|
520
|
+
return None
|
|
521
|
+
avail = tk[j + 1] if j + 1 < len(tk) and tk[j + 1] not in ("winget", "msstore") else ""
|
|
522
|
+
return tk[j].lstrip("><~"), avail
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ── Linux PowerShell: Microsoft's official release tarball, installed to ~/.local (no sudo) ──
|
|
527
|
+
def pwsh_link():
|
|
528
|
+
return os.path.expanduser("~/.local/bin/pwsh")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def linux_arch():
|
|
532
|
+
arch = {"x86_64": "x64", "amd64": "x64", "aarch64": "arm64", "arm64": "arm64",
|
|
533
|
+
"armv7l": "arm32"}.get(platform.machine().lower())
|
|
534
|
+
if arch and glob.glob("/lib/ld-musl-*") and arch == "x64":
|
|
535
|
+
return "linux-musl-x64" # Alpine
|
|
536
|
+
return f"linux-{arch}" if arch else None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def gh_json(url):
|
|
540
|
+
headers = {"Accept": "application/vnd.github+json", "User-Agent": "azup"}
|
|
541
|
+
tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
542
|
+
if tok and url.startswith("https://api.github.com/"):
|
|
543
|
+
headers["Authorization"] = f"Bearer {tok}" # only ever sent to api.github.com
|
|
544
|
+
req = urllib.request.Request(url, headers=headers)
|
|
545
|
+
with urllib.request.urlopen(req, timeout=25) as r:
|
|
546
|
+
return json.load(r)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def tarball_state(repo, check_latest):
|
|
550
|
+
link = pwsh_link()
|
|
551
|
+
if not (os.path.islink(link) and os.path.exists(link)):
|
|
552
|
+
return None
|
|
553
|
+
m = re.search(r"/powershell/(\d+(?:\.\d+)+)/", os.readlink(link))
|
|
554
|
+
if not m:
|
|
555
|
+
return None
|
|
556
|
+
latest = ""
|
|
557
|
+
if check_latest:
|
|
558
|
+
try:
|
|
559
|
+
latest = gh_json(f"https://api.github.com/repos/{repo}/releases/latest")["tag_name"].lstrip("v")
|
|
560
|
+
except Exception:
|
|
561
|
+
pass
|
|
562
|
+
return m.group(1), latest
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def download(url, path):
|
|
566
|
+
req = urllib.request.Request(url, headers={"User-Agent": "azup"})
|
|
567
|
+
with urllib.request.urlopen(req, timeout=60) as r, open(path, "wb") as f:
|
|
568
|
+
total, got = int(r.headers.get("Content-Length") or 0), 0
|
|
569
|
+
while True:
|
|
570
|
+
chunk = r.read(1 << 16)
|
|
571
|
+
if not chunk:
|
|
572
|
+
break
|
|
573
|
+
f.write(chunk)
|
|
574
|
+
got += len(chunk)
|
|
575
|
+
if TTY and total and not is_quiet():
|
|
576
|
+
sys.stdout.write(f"\r {progress(got, total)} {got * 100 // total:3d}%")
|
|
577
|
+
sys.stdout.flush()
|
|
578
|
+
if TTY and not is_quiet():
|
|
579
|
+
sys.stdout.write("\r\x1b[K")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def tarball_install(t, verb, dry):
|
|
583
|
+
arch = linux_arch()
|
|
584
|
+
if not arch:
|
|
585
|
+
print(red(f" unsupported CPU architecture: {platform.machine()}"))
|
|
586
|
+
return False
|
|
587
|
+
print(" " + dim(f"$ download latest {t.ghtar} {arch} tarball -> ~/.local/share/powershell/<version>"))
|
|
588
|
+
if dry:
|
|
589
|
+
return True
|
|
590
|
+
try:
|
|
591
|
+
rel = gh_json(f"https://api.github.com/repos/{t.ghtar}/releases/latest")
|
|
592
|
+
ver = rel["tag_name"].lstrip("v")
|
|
593
|
+
name = f"powershell-{ver}-{arch}.tar.gz"
|
|
594
|
+
assets = {a["name"]: a["browser_download_url"] for a in rel["assets"]}
|
|
595
|
+
if name not in assets or "hashes.sha256" not in assets:
|
|
596
|
+
raise RuntimeError(f"release {ver} has no {name} (or no checksum file)")
|
|
597
|
+
base = os.path.expanduser("~/.local/share/powershell")
|
|
598
|
+
dest = os.path.join(base, ver)
|
|
599
|
+
if verb == "update" and os.path.isfile(os.path.join(dest, "pwsh")):
|
|
600
|
+
print(f" {green('✔')} {ver} is already the latest")
|
|
601
|
+
return True
|
|
602
|
+
os.makedirs(base, exist_ok=True)
|
|
603
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
604
|
+
tgz = os.path.join(tmp, name)
|
|
605
|
+
download(assets[name], tgz)
|
|
606
|
+
req = urllib.request.Request(assets["hashes.sha256"], headers={"User-Agent": "azup"})
|
|
607
|
+
raw = urllib.request.urlopen(req, timeout=30).read()
|
|
608
|
+
sums = raw.decode("utf-16" if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else "utf-8-sig")
|
|
609
|
+
expected = next((l.split()[0] for l in sums.splitlines() if l.strip().endswith(name)), None)
|
|
610
|
+
h = hashlib.sha256()
|
|
611
|
+
with open(tgz, "rb") as f:
|
|
612
|
+
for blk in iter(lambda: f.read(1 << 20), b""):
|
|
613
|
+
h.update(blk)
|
|
614
|
+
if not expected or h.hexdigest().lower() != expected.lower():
|
|
615
|
+
raise RuntimeError("checksum mismatch – refusing to install the download")
|
|
616
|
+
print(f" {green('✔')} sha256 verified")
|
|
617
|
+
os.makedirs(dest, exist_ok=True)
|
|
618
|
+
with tarfile.open(tgz) as tar:
|
|
619
|
+
try:
|
|
620
|
+
tar.extractall(dest, filter="data")
|
|
621
|
+
except TypeError: # Python < 3.12 without the filter argument
|
|
622
|
+
tar.extractall(dest)
|
|
623
|
+
os.chmod(os.path.join(dest, "pwsh"), 0o755)
|
|
624
|
+
link = pwsh_link()
|
|
625
|
+
os.makedirs(os.path.dirname(link), exist_ok=True)
|
|
626
|
+
if os.path.lexists(link):
|
|
627
|
+
os.remove(link)
|
|
628
|
+
os.symlink(os.path.join(dest, "pwsh"), link)
|
|
629
|
+
for d in os.listdir(base): # drop older versions
|
|
630
|
+
if d != ver and re.match(r"^\d+(\.\d+)+", d):
|
|
631
|
+
shutil.rmtree(os.path.join(base, d), ignore_errors=True)
|
|
632
|
+
refresh_path()
|
|
633
|
+
print(f" {green('✔')} PowerShell {ver} -> {link}")
|
|
634
|
+
if os.path.dirname(link) not in os.environ.get("PATH", "").split(":"):
|
|
635
|
+
print(yellow(' add to your shell profile: export PATH="$HOME/.local/bin:$PATH"'))
|
|
636
|
+
return True
|
|
637
|
+
except Exception as e:
|
|
638
|
+
print(red(f" {e}"))
|
|
639
|
+
return False
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def detect(check_latest=True) -> Dict[str, State]:
|
|
644
|
+
refresh_path()
|
|
645
|
+
st = {t.key: State() for t in TOOLS}
|
|
646
|
+
specs = {t.key: spec(t) for t in TOOLS}
|
|
647
|
+
mods = [t for t in TOOLS if t.kind == "psmod"]
|
|
648
|
+
names = ",".join(f"'{t.pkg}'" for t in mods)
|
|
649
|
+
uses = lambda *m: any(s and s[0] in m for s in specs.values())
|
|
650
|
+
|
|
651
|
+
def brew_list():
|
|
652
|
+
if not (uses("brew", "cask") and have("brew")):
|
|
653
|
+
return {}
|
|
654
|
+
rc, out = run(["brew", "list", "--versions"])
|
|
655
|
+
return {p[0]: p[-1] for p in (ln.split() for ln in out.splitlines()) if len(p) >= 2}
|
|
656
|
+
|
|
657
|
+
def brew_out():
|
|
658
|
+
if not (check_latest and uses("brew", "cask") and have("brew")):
|
|
659
|
+
return {}
|
|
660
|
+
rc, out = run(["brew", "outdated", "--json=v2"], 90)
|
|
661
|
+
try:
|
|
662
|
+
d = json.loads(out)
|
|
663
|
+
except ValueError:
|
|
664
|
+
return {}
|
|
665
|
+
return {x["name"].split("/")[-1]: x["current_version"]
|
|
666
|
+
for x in d.get("formulae", []) + d.get("casks", [])}
|
|
667
|
+
|
|
668
|
+
def snap_state(pid):
|
|
669
|
+
if not have("snap"):
|
|
670
|
+
return None
|
|
671
|
+
rc, out = run(["snap", "list", pid])
|
|
672
|
+
for ln in out.splitlines()[1:]:
|
|
673
|
+
p = ln.split()
|
|
674
|
+
if p and p[0] == pid:
|
|
675
|
+
return p[1], ""
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
def ps_installed():
|
|
679
|
+
if not mods:
|
|
680
|
+
return {}
|
|
681
|
+
s = (f"Get-InstalledModule -Name {names} -ErrorAction SilentlyContinue | "
|
|
682
|
+
"Select-Object Name,@{n='Version';e={$_.Version.ToString()}} | ConvertTo-Json -Compress")
|
|
683
|
+
res = {}
|
|
684
|
+
for r in ps_json(s):
|
|
685
|
+
n, v = r["Name"], r["Version"]
|
|
686
|
+
if n not in res or vtuple(v) > vtuple(res[n]):
|
|
687
|
+
res[n] = v
|
|
688
|
+
return res
|
|
689
|
+
|
|
690
|
+
def ps_latest(installed):
|
|
691
|
+
if not (check_latest and installed):
|
|
692
|
+
return {}
|
|
693
|
+
n = ",".join(f"'{x}'" for x in installed)
|
|
694
|
+
s = (f"Find-Module -Name {n} -ErrorAction SilentlyContinue | "
|
|
695
|
+
"Select-Object Name,@{n='Version';e={$_.Version.ToString()}} | ConvertTo-Json -Compress")
|
|
696
|
+
return {r["Name"]: r["Version"] for r in ps_json(s)}
|
|
697
|
+
|
|
698
|
+
def az_exts():
|
|
699
|
+
if not have("az"):
|
|
700
|
+
return {}
|
|
701
|
+
rc, out = run(["az", "extension", "list", "-o", "json"], 60)
|
|
702
|
+
try:
|
|
703
|
+
return {e["name"]: e["version"] for e in json.loads(out)}
|
|
704
|
+
except ValueError:
|
|
705
|
+
return {}
|
|
706
|
+
|
|
707
|
+
def az_ext_latest(exts):
|
|
708
|
+
if not (check_latest and exts):
|
|
709
|
+
return {}
|
|
710
|
+
rc, out = run(["az", "extension", "list-available", "-o", "json"], 90)
|
|
711
|
+
try:
|
|
712
|
+
return {e["name"]: e["version"] for e in json.loads(out) if e["name"] in exts}
|
|
713
|
+
except ValueError:
|
|
714
|
+
return {}
|
|
715
|
+
|
|
716
|
+
def azd_exts():
|
|
717
|
+
if not have("azd"):
|
|
718
|
+
return {}
|
|
719
|
+
rc, out = run(["azd", "extension", "list", "--output", "json"], 60)
|
|
720
|
+
try:
|
|
721
|
+
d = json.loads(out[out.index("["): out.rindex("]") + 1])
|
|
722
|
+
except ValueError:
|
|
723
|
+
return {}
|
|
724
|
+
return {e["id"]: (e.get("installedVersion") or "", e.get("version") or "")
|
|
725
|
+
for e in d if e.get("installedVersion")}
|
|
726
|
+
|
|
727
|
+
def bicep_v():
|
|
728
|
+
if not have("az"):
|
|
729
|
+
return ""
|
|
730
|
+
rc, out = run(["az", "bicep", "version"], 30)
|
|
731
|
+
m = re.search(r"(\d+\.\d+\.\d+)", out)
|
|
732
|
+
return m.group(1) if rc == 0 and m else ""
|
|
733
|
+
|
|
734
|
+
def bin_version(t):
|
|
735
|
+
rc, out = run(list(t.vcmd) or [t.cmd, "--version"], 15)
|
|
736
|
+
m = re.search(r"\d+(\.\d+)+", out)
|
|
737
|
+
if m:
|
|
738
|
+
return m.group(0)
|
|
739
|
+
return "?" if rc == 0 else "" # "" = found on PATH but broken
|
|
740
|
+
|
|
741
|
+
with cf.ThreadPoolExecutor(12) as ex:
|
|
742
|
+
f_bl, f_bo = ex.submit(brew_list), ex.submit(brew_out)
|
|
743
|
+
f_pi, f_ae, f_bv = ex.submit(ps_installed), ex.submit(az_exts), ex.submit(bicep_v)
|
|
744
|
+
f_ax = ex.submit(azd_exts)
|
|
745
|
+
pm = {}
|
|
746
|
+
for t in TOOLS:
|
|
747
|
+
s = specs[t.key]
|
|
748
|
+
if s and s[0] == "winget" and have("winget"):
|
|
749
|
+
pm[t.key] = ex.submit(winget_state, s[1])
|
|
750
|
+
elif s and s[0] == "snap":
|
|
751
|
+
pm[t.key] = ex.submit(snap_state, s[1])
|
|
752
|
+
elif s and s[0] == "tarball":
|
|
753
|
+
pm[t.key] = ex.submit(tarball_state, s[1], check_latest)
|
|
754
|
+
bl, bo, pi, ae, bv = (f.result() for f in (f_bl, f_bo, f_pi, f_ae, f_bv))
|
|
755
|
+
ax = f_ax.result()
|
|
756
|
+
f_pl, f_el = ex.submit(ps_latest, pi), ex.submit(az_ext_latest, ae)
|
|
757
|
+
ext_f = {}
|
|
758
|
+
for t in TOOLS:
|
|
759
|
+
s = specs[t.key]
|
|
760
|
+
if not s:
|
|
761
|
+
continue
|
|
762
|
+
if s[0] in ("brew", "cask"):
|
|
763
|
+
managed = t.short in bl
|
|
764
|
+
else:
|
|
765
|
+
managed = bool(pm.get(t.key) and pm[t.key].result())
|
|
766
|
+
if not managed and t.cmd and have(t.cmd):
|
|
767
|
+
ext_f[t.key] = ex.submit(bin_version, t)
|
|
768
|
+
pl, el = f_pl.result(), f_el.result()
|
|
769
|
+
for k, f in ext_f.items():
|
|
770
|
+
v = f.result()
|
|
771
|
+
if v: # a binary that does not even start counts as missing
|
|
772
|
+
st[k].installed, st[k].managed, st[k].version = True, False, v
|
|
773
|
+
|
|
774
|
+
for t in TOOLS:
|
|
775
|
+
s, sp = st[t.key], specs[t.key]
|
|
776
|
+
if t.kind == "pkg" and sp:
|
|
777
|
+
if sp[0] in ("brew", "cask") and t.short in bl:
|
|
778
|
+
s.installed, s.version = True, bl[t.short]
|
|
779
|
+
s.latest = bo.get(t.short, bl[t.short]) if check_latest else ""
|
|
780
|
+
elif sp[0] in ("winget", "snap", "tarball") and pm.get(t.key) and pm[t.key].result():
|
|
781
|
+
v, avail = pm[t.key].result()
|
|
782
|
+
s.installed, s.version = True, v
|
|
783
|
+
s.latest = (avail or v) if (check_latest and sp[0] == "winget") else \
|
|
784
|
+
(avail if sp[0] == "tarball" else "")
|
|
785
|
+
elif t.kind == "psmod" and t.pkg in pi:
|
|
786
|
+
s.installed, s.version, s.latest = True, pi[t.pkg], pl.get(t.pkg, "")
|
|
787
|
+
elif t.kind == "azext" and t.pkg in ae:
|
|
788
|
+
s.installed, s.version, s.latest = True, ae[t.pkg], el.get(t.pkg, "")
|
|
789
|
+
elif t.kind == "azdext" and t.pkg in ax:
|
|
790
|
+
s.installed, s.version = True, ax[t.pkg][0]
|
|
791
|
+
s.latest = ax[t.pkg][1] if check_latest else ""
|
|
792
|
+
elif t.kind == "bicep" and bv:
|
|
793
|
+
s.installed, s.version = True, bv
|
|
794
|
+
return st
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# ───────────────────────────── display ─────────────────────────────
|
|
798
|
+
def status_cell(s):
|
|
799
|
+
if not s.installed:
|
|
800
|
+
return dim("○ not installed")
|
|
801
|
+
if not s.managed:
|
|
802
|
+
return yellow("● external")
|
|
803
|
+
if s.outdated:
|
|
804
|
+
return yellow("▲ update available")
|
|
805
|
+
return green("✔ up to date") if s.latest else cyan("● installed")
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def print_table(st):
|
|
809
|
+
w_name = max(len(t.name) for t in TOOLS) + 2
|
|
810
|
+
last = None
|
|
811
|
+
for t in TOOLS:
|
|
812
|
+
if t.group != last:
|
|
813
|
+
print("\n " + bold(azure(t.group.upper())))
|
|
814
|
+
last = t.group
|
|
815
|
+
s = st[t.key]
|
|
816
|
+
new = (" → " + green(s.latest)) if s.outdated else ""
|
|
817
|
+
print(f" {pad(bold(t.name), w_name)}{pad(dim(s.version or '–'), 12)}"
|
|
818
|
+
f"{pad(status_cell(s), 22)}{new}")
|
|
819
|
+
n_in = sum(s.installed for s in st.values())
|
|
820
|
+
n_out = sum(s.outdated for s in st.values())
|
|
821
|
+
print(f"\n {green(str(n_in))} installed · {yellow(str(n_out))} outdated · "
|
|
822
|
+
f"{dim(str(len(st) - n_in))} not installed\n")
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# ───────────────────────────── what's new ─────────────────────────────
|
|
826
|
+
def ver_of(tag):
|
|
827
|
+
m = re.search(r"\d+\.\d+(?:\.\d+)*", tag or "")
|
|
828
|
+
return m.group(0) if m else ""
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def notes_from_github(t, cur, new):
|
|
832
|
+
rels = gh_json(f"https://api.github.com/repos/{t.repo}/releases?per_page=60")
|
|
833
|
+
keep = []
|
|
834
|
+
for r in rels:
|
|
835
|
+
if r.get("draft") or r.get("prerelease"):
|
|
836
|
+
continue
|
|
837
|
+
if t.tag_re and not re.search(t.tag_re, r["tag_name"]):
|
|
838
|
+
continue
|
|
839
|
+
v = ver_of(r["tag_name"])
|
|
840
|
+
if not v or vtuple(v) <= vtuple(cur) or (new and vtuple(v) > vtuple(new)):
|
|
841
|
+
continue
|
|
842
|
+
keep.append((vtuple(v), v, r))
|
|
843
|
+
keep.sort(key=lambda x: x[0], reverse=True)
|
|
844
|
+
return {"items": [{"version": v, "date": (r.get("published_at") or "")[:10],
|
|
845
|
+
"body": r.get("body") or "", "url": r["html_url"]} for _, v, r in keep],
|
|
846
|
+
"page": f"https://github.com/{t.repo}/releases"}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def notes_from_history(t, cur, new):
|
|
850
|
+
"""az extensions keep a HISTORY.rst per extension in Azure/azure-cli-extensions."""
|
|
851
|
+
base = f"Azure/azure-cli-extensions/main/src/{t.pkg}/HISTORY.rst"
|
|
852
|
+
req = urllib.request.Request("https://raw.githubusercontent.com/" + base, headers={"User-Agent": "azup"})
|
|
853
|
+
text = urllib.request.urlopen(req, timeout=25).read().decode("utf-8", "replace")
|
|
854
|
+
parts = re.split(r"^(\d+\.\d+\.\d+\S*)\n[+~=\-]{3,}[ \t]*$", text, flags=re.M)
|
|
855
|
+
items = []
|
|
856
|
+
for i in range(1, len(parts) - 1, 2):
|
|
857
|
+
v = parts[i]
|
|
858
|
+
if vtuple(v) > vtuple(cur) and (not new or vtuple(v) <= vtuple(new)):
|
|
859
|
+
items.append({"version": v, "date": "", "body": parts[i + 1].strip(),
|
|
860
|
+
"url": f"https://github.com/Azure/azure-cli-extensions/blob/main/src/{t.pkg}/HISTORY.rst"})
|
|
861
|
+
return {"items": items, "page": f"https://github.com/Azure/azure-cli-extensions/blob/main/src/{t.pkg}/HISTORY.rst"}
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def notes_from_markdown(t, cur, new):
|
|
865
|
+
req = urllib.request.Request(t.notes_url, headers={"User-Agent": "azup"})
|
|
866
|
+
text = urllib.request.urlopen(req, timeout=25).read().decode("utf-8", "replace")
|
|
867
|
+
parts = re.split(r"^## ([^\n]+)\n+Version (\d+(?:\.\d+)*)[^\n]*\n", text, flags=re.M)
|
|
868
|
+
items = []
|
|
869
|
+
for i in range(1, len(parts) - 2, 3):
|
|
870
|
+
date, v, body = parts[i], parts[i + 1], parts[i + 2]
|
|
871
|
+
if vtuple(v) > vtuple(cur) and (not new or vtuple(v) <= vtuple(new)):
|
|
872
|
+
items.append((vtuple(v), {"version": v, "date": date, "body": body.strip(),
|
|
873
|
+
"url": "https://learn.microsoft.com/cli/azure/release-notes-azure-cli"}))
|
|
874
|
+
items.sort(key=lambda x: x[0], reverse=True)
|
|
875
|
+
return {"items": [x[1] for x in items], "page": "https://learn.microsoft.com/cli/azure/release-notes-azure-cli"}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def fetch_notes(t, cur, new):
|
|
879
|
+
"""Release notes for versions newer than `cur` (up to `new`). Never raises."""
|
|
880
|
+
try:
|
|
881
|
+
if t.kind == "azext":
|
|
882
|
+
return notes_from_history(t, cur, new)
|
|
883
|
+
if t.notes_url:
|
|
884
|
+
return notes_from_markdown(t, cur, new)
|
|
885
|
+
if not t.repo:
|
|
886
|
+
return {"error": "no release-notes source known for this tool"}
|
|
887
|
+
return notes_from_github(t, cur, new)
|
|
888
|
+
except urllib.error.HTTPError as e:
|
|
889
|
+
if e.code in (403, 429):
|
|
890
|
+
return {"error": "GitHub rate limit reached – set GITHUB_TOKEN to raise it"}
|
|
891
|
+
return {"error": f"HTTP {e.code}"}
|
|
892
|
+
except Exception as e:
|
|
893
|
+
return {"error": f"couldn't fetch release notes ({e.__class__.__name__}) – offline?"}
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
SKIP_HEADINGS = re.compile(r"docker|install|download|checksum|asset|release notes?$", re.I)
|
|
897
|
+
SKIP_LINES = re.compile(r"^\s*(gallery module|to install|to update from|full changelog)", re.I)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def render_notes(body, width, max_lines):
|
|
901
|
+
body = re.sub(r"```.*?```", "", body, flags=re.S) # install snippets etc.
|
|
902
|
+
out, skipping = [], False
|
|
903
|
+
for raw in body.splitlines():
|
|
904
|
+
l = re.sub(r"<!--.*?-->|<[^>]+>|!\[[^\]]*\]\([^)]*\)", "", raw).rstrip()
|
|
905
|
+
l = re.sub(r"\[(\[[^\]]+\])\]\([^)]*\)", r"\1", l) # [[#123]](url) -> [#123]
|
|
906
|
+
l = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", l) # [text](url) -> text
|
|
907
|
+
l = re.sub(r"\[(#\d+)\]", r"\1", l).replace("**", "").replace("`", "")
|
|
908
|
+
if not l.strip():
|
|
909
|
+
continue
|
|
910
|
+
head = re.match(r"^\s*#{1,6}\s*(.*)", l)
|
|
911
|
+
if head:
|
|
912
|
+
skipping = bool(SKIP_HEADINGS.search(head.group(1).strip()))
|
|
913
|
+
if skipping:
|
|
914
|
+
continue
|
|
915
|
+
elif skipping or SKIP_LINES.match(l):
|
|
916
|
+
continue
|
|
917
|
+
bullet = re.match(r"^\s*[-*+]\s+(.*)", l)
|
|
918
|
+
if head:
|
|
919
|
+
out.append(bold(head.group(1).strip()))
|
|
920
|
+
else:
|
|
921
|
+
txt = bullet.group(1) if bullet else l.strip()
|
|
922
|
+
for k, w in enumerate(textwrap.wrap(txt, max(30, width - 6)) or [""]):
|
|
923
|
+
out.append(("• " if bullet and k == 0 else " " if bullet else "") + w)
|
|
924
|
+
more = max(0, len(out) - max_lines)
|
|
925
|
+
return out[:max_lines], more
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def print_changes(tools, st, per_tool=4, lines=12, latest_only=False):
|
|
929
|
+
"""Print what's new for each tool, between the installed and the latest version."""
|
|
930
|
+
if not tools:
|
|
931
|
+
return
|
|
932
|
+
width = min(shutil.get_terminal_size((100, 24)).columns, 110) - 4
|
|
933
|
+
with Spinner("reading release notes…"):
|
|
934
|
+
with cf.ThreadPoolExecutor(6) as ex:
|
|
935
|
+
futs = {t.key: ex.submit(fetch_notes, t, "0" if latest_only else st[t.key].version,
|
|
936
|
+
"" if latest_only else st[t.key].latest) for t in tools}
|
|
937
|
+
res = {k: f.result() for k, f in futs.items()}
|
|
938
|
+
for t in tools:
|
|
939
|
+
s, r = st[t.key], res[t.key]
|
|
940
|
+
move = f"{s.version} → {s.latest}" if s.outdated else (s.version or "")
|
|
941
|
+
print(f"\n {azure('▶')} {bold(t.name)} {yellow(move) if s.outdated else dim(move)}")
|
|
942
|
+
if r.get("error") or not r.get("items"):
|
|
943
|
+
print(f" {dim(r.get('error') or 'no release notes found')}"
|
|
944
|
+
+ (f" {dim(r['page'])}" if r.get("page") else ""))
|
|
945
|
+
continue
|
|
946
|
+
items = r["items"][:1] if latest_only else r["items"]
|
|
947
|
+
for it in items[:per_tool]:
|
|
948
|
+
print(f" {bold(green('v' + it['version']))} {dim(it['date'])}")
|
|
949
|
+
body, more = render_notes(it["body"], width, lines)
|
|
950
|
+
for l in body:
|
|
951
|
+
print(" " + l)
|
|
952
|
+
if more:
|
|
953
|
+
print(dim(f" … +{more} more lines {it['url']}"))
|
|
954
|
+
left = len(items) - per_tool
|
|
955
|
+
if left > 0:
|
|
956
|
+
print(dim(f" … and {left} older release{'s' if left != 1 else ''}: {r['page']}"))
|
|
957
|
+
print()
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def proceed(actions, st, yes=False):
|
|
961
|
+
"""'Proceed?' that also offers 'c' = see what's new for the updates in the plan."""
|
|
962
|
+
if yes:
|
|
963
|
+
return True
|
|
964
|
+
if not (TTY and sys.stdin.isatty()):
|
|
965
|
+
return False
|
|
966
|
+
ups = [t for t, v in actions if v == "update"]
|
|
967
|
+
while True:
|
|
968
|
+
a = input(f" Proceed? {dim('[Y/n/c] c = see what' + chr(39) + 's new first') if ups else dim('[Y/n]')} ").strip().lower()
|
|
969
|
+
if a in ("", "y", "yes"):
|
|
970
|
+
return True
|
|
971
|
+
if a in ("n", "no"):
|
|
972
|
+
return False
|
|
973
|
+
if a == "c" and ups:
|
|
974
|
+
print_changes(ups, st)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
# ───────────────────────────── execution ─────────────────────────────
|
|
978
|
+
# ───────────────────────────── parallel engine ─────────────────────────────
|
|
979
|
+
# Work is split into "lanes", one per backend, that run side by side:
|
|
980
|
+
# brew (formulas are batched into ONE `brew` call so Homebrew downloads in parallel itself)
|
|
981
|
+
# ps-az / ps-graph (PowerShell modules; Az and Graph share no dependencies, Graph/Beta/Entra do)
|
|
982
|
+
# az (extensions + Bicep) · azd (extensions) · winget · tarball
|
|
983
|
+
# Inside a lane steps run one after another. A step that needs another tool (modules need PowerShell,
|
|
984
|
+
# `az` extensions need Azure CLI...) waits for it, and fails cleanly if that tool failed.
|
|
985
|
+
QUIET = threading.local() # set in worker threads: no prompts, stdin closed, output captured
|
|
986
|
+
PREREQ = {"psmod": "pwsh", "azext": "az", "bicep": "az", "azdext": "azd"}
|
|
987
|
+
LANE_LABEL = {"brew": "brew", "ps-az": "pwsh", "ps-graph": "pwsh", "az": "az", "azd": "azd",
|
|
988
|
+
"winget": "winget", "tarball": "tarball", "snap": "snap"}
|
|
989
|
+
_PS_PRE_LOCK, _PS_PRE_DONE = threading.Lock(), [False]
|
|
990
|
+
_SHOWN: set = set()
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def is_quiet():
|
|
994
|
+
return getattr(QUIET, "on", False)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def lane_of(t: Tool):
|
|
998
|
+
if t.kind == "pkg":
|
|
999
|
+
return {"cask": "brew"}.get(spec(t)[0], spec(t)[0])
|
|
1000
|
+
if t.kind == "psmod":
|
|
1001
|
+
return "ps-az" if t.pkg == "Az" or t.pkg.startswith("Az.") else "ps-graph"
|
|
1002
|
+
return {"azext": "az", "bicep": "az", "azdext": "azd"}[t.kind]
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
class JobBuf:
|
|
1006
|
+
"""Captures one job's output; remembers the last meaningful line for the status board."""
|
|
1007
|
+
def __init__(self):
|
|
1008
|
+
self.lines, self.cur, self.saved, self.last = [], "", "", ""
|
|
1009
|
+
|
|
1010
|
+
def write(self, x):
|
|
1011
|
+
for ch in x:
|
|
1012
|
+
if ch == "\r":
|
|
1013
|
+
self.saved, self.cur = self.cur, ""
|
|
1014
|
+
elif ch == "\n":
|
|
1015
|
+
line = self.cur or self.saved
|
|
1016
|
+
self.lines.append(line)
|
|
1017
|
+
if line.strip():
|
|
1018
|
+
self.last = ANSI.sub("", line).strip()
|
|
1019
|
+
self.cur = self.saved = ""
|
|
1020
|
+
else:
|
|
1021
|
+
self.cur += ch
|
|
1022
|
+
self.saved = ""
|
|
1023
|
+
if self.cur.strip():
|
|
1024
|
+
self.last = ANSI.sub("", self.cur).strip()
|
|
1025
|
+
return len(x)
|
|
1026
|
+
|
|
1027
|
+
def flush(self):
|
|
1028
|
+
pass
|
|
1029
|
+
|
|
1030
|
+
def text(self):
|
|
1031
|
+
return "\n".join(self.lines + ([self.cur] if self.cur else []))
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
class Router:
|
|
1035
|
+
"""sys.stdout replacement: each worker thread writes into its own job buffer."""
|
|
1036
|
+
def __init__(self, real):
|
|
1037
|
+
self.real, self.bufs = real, {}
|
|
1038
|
+
|
|
1039
|
+
def attach(self, buf):
|
|
1040
|
+
self.bufs[threading.get_ident()] = buf
|
|
1041
|
+
|
|
1042
|
+
def write(self, x):
|
|
1043
|
+
b = self.bufs.get(threading.get_ident())
|
|
1044
|
+
return self.real.write(x) if b is None else b.write(x)
|
|
1045
|
+
|
|
1046
|
+
def flush(self):
|
|
1047
|
+
self.real.flush()
|
|
1048
|
+
|
|
1049
|
+
def isatty(self):
|
|
1050
|
+
return False
|
|
1051
|
+
|
|
1052
|
+
def reconfigure(self, **kw):
|
|
1053
|
+
pass
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
class Job:
|
|
1057
|
+
def __init__(self, tool, verb):
|
|
1058
|
+
self.tool, self.verb, self.lane = tool, verb, lane_of(tool)
|
|
1059
|
+
self.state, self.note, self.ok = "queued", "", None
|
|
1060
|
+
self.t0 = self.t1 = 0.0
|
|
1061
|
+
self.buf = JobBuf()
|
|
1062
|
+
self.wait_for = ""
|
|
1063
|
+
|
|
1064
|
+
def took(self):
|
|
1065
|
+
return (self.t1 or time.time()) - self.t0 if self.t0 else 0.0
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
class Board:
|
|
1069
|
+
"""Live status board: one line per task, updated in place."""
|
|
1070
|
+
def __init__(self, jobs, real, title):
|
|
1071
|
+
self.jobs, self.real, self.title = jobs, real, title
|
|
1072
|
+
self.stop_ev, self.H = threading.Event(), len(jobs) + 2
|
|
1073
|
+
self.t = threading.Thread(target=self._run, daemon=True)
|
|
1074
|
+
|
|
1075
|
+
def frame(self):
|
|
1076
|
+
n = len(self.jobs)
|
|
1077
|
+
cols = shutil.get_terminal_size((100, 24)).columns
|
|
1078
|
+
spin = Spinner.FR[int(time.time() * 12) % 10]
|
|
1079
|
+
done = sum(j.state in ("ok", "fail") for j in self.jobs)
|
|
1080
|
+
run = sum(j.state == "running" for j in self.jobs)
|
|
1081
|
+
out = [f" {azure('»')} {bold(self.title)} {dim(f'{run} running · {done}/{n} done')}"]
|
|
1082
|
+
avail = max(10, cols - 52)
|
|
1083
|
+
for j in self.jobs:
|
|
1084
|
+
if j.state == "queued":
|
|
1085
|
+
icon, hint = dim("○"), dim("queued")
|
|
1086
|
+
elif j.state == "waiting":
|
|
1087
|
+
icon, hint = yellow("◌"), yellow(f"waiting for {j.wait_for}")
|
|
1088
|
+
elif j.state == "running":
|
|
1089
|
+
icon, hint = cyan(spin), dim(clip(j.buf.last, avail))
|
|
1090
|
+
elif j.state == "ok":
|
|
1091
|
+
icon, hint = green("✔"), dim("done")
|
|
1092
|
+
else:
|
|
1093
|
+
icon, hint = red("✘"), red(clip(j.note or "failed", avail))
|
|
1094
|
+
t = f"{j.took():4.0f}s" if j.t0 else " "
|
|
1095
|
+
out.append(f" {icon} {pad(bold(j.tool.name) if j.state == 'running' else j.tool.name, 24)}"
|
|
1096
|
+
f"{pad(dim(LANE_LABEL.get(j.lane, j.lane)), 9)}{dim(t)} {hint}")
|
|
1097
|
+
out.append(f" {progress(done, n)} {dim(f'{done}/{n}')}")
|
|
1098
|
+
return out
|
|
1099
|
+
|
|
1100
|
+
def draw(self, first):
|
|
1101
|
+
if not first:
|
|
1102
|
+
self.real.write(f"\x1b[{self.H}A")
|
|
1103
|
+
self.real.write("".join("\x1b[K" + l + "\n" for l in self.frame()))
|
|
1104
|
+
self.real.flush()
|
|
1105
|
+
|
|
1106
|
+
def _run(self):
|
|
1107
|
+
first = True
|
|
1108
|
+
while not self.stop_ev.is_set():
|
|
1109
|
+
self.draw(first)
|
|
1110
|
+
first = False
|
|
1111
|
+
time.sleep(0.1)
|
|
1112
|
+
self.draw(first)
|
|
1113
|
+
|
|
1114
|
+
def start(self):
|
|
1115
|
+
self.real.write("\x1b[?25l")
|
|
1116
|
+
self.t.start()
|
|
1117
|
+
|
|
1118
|
+
def stop(self):
|
|
1119
|
+
self.stop_ev.set()
|
|
1120
|
+
self.t.join()
|
|
1121
|
+
self.real.write("\x1b[?25h")
|
|
1122
|
+
self.real.flush()
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def ps_preflight():
|
|
1126
|
+
"""Trust PSGallery once, before concurrent module installs (two writers would race on that file)."""
|
|
1127
|
+
with _PS_PRE_LOCK:
|
|
1128
|
+
if not _PS_PRE_DONE[0]:
|
|
1129
|
+
subprocess.run(resolve_exe(ps_cmd("Set-PSRepository PSGallery -InstallationPolicy Trusted "
|
|
1130
|
+
"-ErrorAction SilentlyContinue")),
|
|
1131
|
+
capture_output=True, stdin=subprocess.DEVNULL, timeout=120)
|
|
1132
|
+
_PS_PRE_DONE[0] = True
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def run_one(t: Tool, verb, dry):
|
|
1136
|
+
"""Run a single install/update with its pre-checks. Returns (ok, note)."""
|
|
1137
|
+
req = requires(t)
|
|
1138
|
+
refresh_path()
|
|
1139
|
+
if not dry and not has_requirement(req):
|
|
1140
|
+
print(f"\n {red('✘')} {t.name}: needs {bold(req)} – install it first")
|
|
1141
|
+
return False, f"needs {req} – install it first"
|
|
1142
|
+
if not dry and t.kind == "azdext" and t.min_azd:
|
|
1143
|
+
rc_, out_ = run(["azd", "version"], 20)
|
|
1144
|
+
m_ = re.search(r"(\d+\.\d+\.\d+)", out_)
|
|
1145
|
+
have_v = m_.group(1) if m_ else "0"
|
|
1146
|
+
if vtuple(have_v) < vtuple(t.min_azd):
|
|
1147
|
+
note = f"needs azd >= {t.min_azd} (you have {have_v}) – update azd first"
|
|
1148
|
+
print(f"\n {red('✘')} {t.name}: {note}")
|
|
1149
|
+
return False, note
|
|
1150
|
+
if not dry and t.kind == "psmod":
|
|
1151
|
+
works, info = ps_works()
|
|
1152
|
+
if not works:
|
|
1153
|
+
if "ps" not in _SHOWN:
|
|
1154
|
+
_SHOWN.add("ps")
|
|
1155
|
+
explain_broken_powershell(info)
|
|
1156
|
+
return False, "PowerShell does not start"
|
|
1157
|
+
if is_quiet():
|
|
1158
|
+
ps_preflight()
|
|
1159
|
+
if t.kind == "pkg" and spec(t)[0] == "tarball":
|
|
1160
|
+
ok = tarball_install(t, verb, dry)
|
|
1161
|
+
else:
|
|
1162
|
+
ok = stream(build_cmd(t, verb), dry)
|
|
1163
|
+
if t.key == "pwsh":
|
|
1164
|
+
_PS_CHECK.clear() # re-test the freshly installed PowerShell
|
|
1165
|
+
return ok, ""
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def run_sequential(actions, dry):
|
|
1169
|
+
results = []
|
|
1170
|
+
for i, (t, verb) in enumerate(actions, 1):
|
|
1171
|
+
label = "Installing" if verb == "install" else "Updating"
|
|
1172
|
+
print(f"\n {progress(i - 1, len(actions))} {dim(f'{i}/{len(actions)}')} "
|
|
1173
|
+
f"{azure('▶')} {bold(f'{label} {t.name}')}")
|
|
1174
|
+
t0 = time.time()
|
|
1175
|
+
ok, note = run_one(t, verb, dry)
|
|
1176
|
+
results.append((t, verb, ok, time.time() - t0, note))
|
|
1177
|
+
return results
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def run_parallel(actions, jobs, yes=False):
|
|
1181
|
+
real = sys.stdout
|
|
1182
|
+
jl = [Job(t, v) for t, v in actions]
|
|
1183
|
+
by_key = {j.tool.key: j for j in jl}
|
|
1184
|
+
done_ev = {j.tool.key: threading.Event() for j in jl}
|
|
1185
|
+
emit_lock = threading.Lock()
|
|
1186
|
+
interactive = TTY and sys.stdin.isatty()
|
|
1187
|
+
|
|
1188
|
+
def finish(j, ok, note=""):
|
|
1189
|
+
j.ok, j.note, j.t1 = ok, note, time.time()
|
|
1190
|
+
j.state = "ok" if ok else "fail"
|
|
1191
|
+
done_ev[j.tool.key].set()
|
|
1192
|
+
if not use_board: # plain/log mode: one block per finished job
|
|
1193
|
+
with emit_lock:
|
|
1194
|
+
real.write(f"\n {green('✔') if ok else red('✘')} {bold(j.tool.name)} "
|
|
1195
|
+
f"{dim(LANE_LABEL.get(j.lane, j.lane) + ' · ' + format(j.took(), '.0f') + 's')}"
|
|
1196
|
+
+ (f" {red(note)}" if note else "") + "\n")
|
|
1197
|
+
for l in j.buf.text().splitlines():
|
|
1198
|
+
real.write(dim(" " + l) + "\n")
|
|
1199
|
+
real.flush()
|
|
1200
|
+
|
|
1201
|
+
# 1) things that may ask for a password (macOS casks) run first, one at a time, with a real terminal
|
|
1202
|
+
first = [j for j in jl if j.tool.kind == "pkg" and spec(j.tool)[0] == "cask"] if interactive else []
|
|
1203
|
+
for j in first:
|
|
1204
|
+
use_board = False
|
|
1205
|
+
print(f"\n {azure('▶')} {bold(('Installing ' if j.verb == 'install' else 'Updating ') + j.tool.name)} {dim('(may ask for your password)')}")
|
|
1206
|
+
j.t0, j.state = time.time(), "running"
|
|
1207
|
+
ok, note = run_one(j.tool, j.verb, False)
|
|
1208
|
+
j.ok, j.note, j.t1, j.state = ok, note, time.time(), "ok" if ok else "fail"
|
|
1209
|
+
done_ev[j.tool.key].set()
|
|
1210
|
+
rest = [j for j in jl if j not in first]
|
|
1211
|
+
if not rest:
|
|
1212
|
+
return [(j.tool, j.verb, j.ok, j.took(), j.note) for j in jl]
|
|
1213
|
+
|
|
1214
|
+
use_board = interactive and TTY and len(rest) + 3 <= shutil.get_terminal_size((100, 24)).lines
|
|
1215
|
+
lanes = {}
|
|
1216
|
+
for j in rest:
|
|
1217
|
+
lanes.setdefault(j.lane, []).append(j)
|
|
1218
|
+
for lane in lanes.values(): # prerequisites first, so dependents unblock early
|
|
1219
|
+
lane.sort(key=lambda j: 0 if j.tool.key in PREREQ.values() else 1)
|
|
1220
|
+
router = Router(real)
|
|
1221
|
+
|
|
1222
|
+
def wait_prereq(j):
|
|
1223
|
+
need = PREREQ.get(j.tool.kind)
|
|
1224
|
+
if need and need in done_ev and need != j.tool.key:
|
|
1225
|
+
j.state, j.wait_for = "waiting", {"pwsh": "PowerShell", "az": "Azure CLI", "azd": "azd"}[need]
|
|
1226
|
+
done_ev[need].wait()
|
|
1227
|
+
if by_key[need].ok is False:
|
|
1228
|
+
return f"needs {j.wait_for} – it failed"
|
|
1229
|
+
return ""
|
|
1230
|
+
|
|
1231
|
+
def run_job(j, shared_buf=None):
|
|
1232
|
+
if shared_buf is not None:
|
|
1233
|
+
j.buf = shared_buf
|
|
1234
|
+
router.attach(j.buf)
|
|
1235
|
+
why = wait_prereq(j)
|
|
1236
|
+
j.t0, j.state = time.time(), "running"
|
|
1237
|
+
if why:
|
|
1238
|
+
finish(j, False, why)
|
|
1239
|
+
return
|
|
1240
|
+
try:
|
|
1241
|
+
ok, note = run_one(j.tool, j.verb, False)
|
|
1242
|
+
except Exception as e: # never let a lane die silently
|
|
1243
|
+
ok, note = False, f"{e.__class__.__name__}: {e}"
|
|
1244
|
+
finish(j, ok, note)
|
|
1245
|
+
|
|
1246
|
+
def brew_batch(batch):
|
|
1247
|
+
"""One `brew install a b c` for several formulas; if it fails, redo them one by one."""
|
|
1248
|
+
shared = JobBuf()
|
|
1249
|
+
router.attach(shared)
|
|
1250
|
+
for j in batch:
|
|
1251
|
+
j.buf, j.t0, j.state = shared, time.time(), "running"
|
|
1252
|
+
verb = batch[0].verb
|
|
1253
|
+
cmd = ["brew", "install" if verb == "install" else "upgrade"] + [spec(j.tool)[1] for j in batch]
|
|
1254
|
+
try:
|
|
1255
|
+
ok = stream(cmd, False)
|
|
1256
|
+
except Exception:
|
|
1257
|
+
ok = False
|
|
1258
|
+
if ok:
|
|
1259
|
+
for j in batch[1:]:
|
|
1260
|
+
j.buf = JobBuf()
|
|
1261
|
+
j.buf.write("(installed in the same brew batch)\n")
|
|
1262
|
+
for j in batch:
|
|
1263
|
+
finish(j, True)
|
|
1264
|
+
return
|
|
1265
|
+
print(f"\n {yellow('▲')} batch failed – retrying one by one")
|
|
1266
|
+
for j in batch:
|
|
1267
|
+
j.buf = JobBuf()
|
|
1268
|
+
run_job(j)
|
|
1269
|
+
|
|
1270
|
+
def lane_worker(name, js):
|
|
1271
|
+
QUIET.on = True
|
|
1272
|
+
try:
|
|
1273
|
+
steps, cur = [], []
|
|
1274
|
+
if name == "brew": # batch plain formulas per verb, prerequisites first
|
|
1275
|
+
for grp in ([j for j in js if j.tool.key in PREREQ.values()], [j for j in js if j.tool.key not in PREREQ.values()]):
|
|
1276
|
+
for v in ("install", "update"):
|
|
1277
|
+
g = [j for j in grp if j.verb == v and spec(j.tool)[0] == "brew"]
|
|
1278
|
+
if len(g) >= 2:
|
|
1279
|
+
steps.append(g)
|
|
1280
|
+
else:
|
|
1281
|
+
steps += [[j] for j in g]
|
|
1282
|
+
steps += [[j] for j in js if spec(j.tool)[0] == "cask"]
|
|
1283
|
+
else:
|
|
1284
|
+
steps = [[j] for j in js]
|
|
1285
|
+
for step in steps:
|
|
1286
|
+
if len(step) > 1:
|
|
1287
|
+
brew_batch(step)
|
|
1288
|
+
else:
|
|
1289
|
+
run_job(step[0])
|
|
1290
|
+
finally:
|
|
1291
|
+
for j in js: # unblock dependents whatever happens
|
|
1292
|
+
if j.ok is None:
|
|
1293
|
+
finish(j, False, "interrupted")
|
|
1294
|
+
|
|
1295
|
+
title = f"{len(jl)} tasks · {len(lanes)} lanes in parallel"
|
|
1296
|
+
board = Board(jl, real, title) if use_board else None
|
|
1297
|
+
if not use_board:
|
|
1298
|
+
real.write(f"\n {azure('»')} {bold(title)}\n")
|
|
1299
|
+
sys.stdout = router
|
|
1300
|
+
if board:
|
|
1301
|
+
board.start()
|
|
1302
|
+
try:
|
|
1303
|
+
order = sorted(lanes, key=lambda n: any(PREREQ.get(j.tool.kind) for j in lanes[n])) # independent lanes first
|
|
1304
|
+
with cf.ThreadPoolExecutor(max_workers=max(1, min(jobs, len(lanes)))) as ex:
|
|
1305
|
+
futs = [ex.submit(lane_worker, n, lanes[n]) for n in order]
|
|
1306
|
+
for f in futs:
|
|
1307
|
+
f.result()
|
|
1308
|
+
finally:
|
|
1309
|
+
sys.stdout = real
|
|
1310
|
+
if board:
|
|
1311
|
+
board.stop()
|
|
1312
|
+
# 2) anything that needs you (Homebrew asking to trust a tap) is handled now, one at a time
|
|
1313
|
+
for j in jl:
|
|
1314
|
+
txt = j.buf.text()
|
|
1315
|
+
if j.ok is False and "untrusted tap" in txt and interactive and trust_tap(txt):
|
|
1316
|
+
print(f"\n {azure('▶')} {bold('Retrying ' + j.tool.name)}")
|
|
1317
|
+
t0 = time.time()
|
|
1318
|
+
ok, note = run_one(j.tool, j.verb, False)
|
|
1319
|
+
j.ok, j.note, j.t1, j.t0, j.state = ok, note, time.time(), t0, "ok" if ok else "fail"
|
|
1320
|
+
if use_board: # show why things failed
|
|
1321
|
+
for j in jl:
|
|
1322
|
+
if j.ok is False and j.buf.lines:
|
|
1323
|
+
print(f"\n {red('✘')} {bold(j.tool.name)} {dim('– last output')}")
|
|
1324
|
+
for l in j.buf.text().splitlines()[-25:]:
|
|
1325
|
+
print(dim(" " + l))
|
|
1326
|
+
return [(j.tool, j.verb, bool(j.ok), j.took(), j.note) for j in jl]
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def resolve_jobs(jobs):
|
|
1330
|
+
if jobs:
|
|
1331
|
+
return max(1, int(jobs))
|
|
1332
|
+
env = os.environ.get("AZUP_JOBS", "")
|
|
1333
|
+
return max(1, int(env)) if env.isdigit() else 4
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def can_parallel(actions):
|
|
1337
|
+
lanes = {lane_of(t) for t, _ in actions}
|
|
1338
|
+
brews = sum(1 for t, _ in actions if t.kind == "pkg" and spec(t)[0] == "brew")
|
|
1339
|
+
return len(actions) >= 2 and (len(lanes) >= 2 or brews >= 2)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def build_cmd(t: Tool, verb: str):
|
|
1344
|
+
inst = verb == "install"
|
|
1345
|
+
if t.kind == "pkg":
|
|
1346
|
+
mgr, pid = spec(t)
|
|
1347
|
+
if mgr == "brew":
|
|
1348
|
+
return ["brew", "install" if inst else "upgrade", pid]
|
|
1349
|
+
if mgr == "cask":
|
|
1350
|
+
return ["brew", "install" if inst else "upgrade", "--cask", pid]
|
|
1351
|
+
if mgr == "winget":
|
|
1352
|
+
return ["winget", "install" if inst else "upgrade", "--id", pid, "-e",
|
|
1353
|
+
"--accept-package-agreements", "--accept-source-agreements"]
|
|
1354
|
+
return ["sudo", "snap", "install" if inst else "refresh", pid] + \
|
|
1355
|
+
(["--classic"] if inst else [])
|
|
1356
|
+
if t.kind == "psmod":
|
|
1357
|
+
if inst:
|
|
1358
|
+
return ps_cmd(("" if _PS_PRE_DONE[0] else
|
|
1359
|
+
"Set-PSRepository PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue;")
|
|
1360
|
+
+ f"Install-Module {t.pkg} -Scope CurrentUser -Force -AllowClobber "
|
|
1361
|
+
"-ErrorAction Stop")
|
|
1362
|
+
return ps_cmd(f"Update-Module {t.pkg} -Force -ErrorAction Stop")
|
|
1363
|
+
if t.kind == "azext":
|
|
1364
|
+
return ["az", "extension", "add", "-n", t.pkg, "--upgrade", "-y"]
|
|
1365
|
+
if t.kind == "azdext":
|
|
1366
|
+
return ["azd", "extension", "install" if inst else "update", t.pkg]
|
|
1367
|
+
return ["az", "bicep", "install" if inst else "upgrade"]
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def requires(t: Tool):
|
|
1371
|
+
if t.kind == "pkg":
|
|
1372
|
+
return "" if spec(t)[0] == "tarball" else spec(t)[0] # tarball needs no package manager
|
|
1373
|
+
return {"psmod": "powershell", "azdext": "azd"}.get(t.kind, "az")
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
_PS_CHECK = {}
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def ps_works(refresh=False):
|
|
1380
|
+
"""Smoke-test PowerShell: (ok, output). A broken pwsh would otherwise fail every module step."""
|
|
1381
|
+
exe = ps_exe()
|
|
1382
|
+
if not exe:
|
|
1383
|
+
return False, "PowerShell was not found on PATH."
|
|
1384
|
+
if refresh or exe not in _PS_CHECK:
|
|
1385
|
+
try:
|
|
1386
|
+
p = subprocess.run(resolve_exe([exe, "-NoProfile", "-NonInteractive", "-Command", "exit 0"]),
|
|
1387
|
+
capture_output=True, text=True, timeout=90, stdin=subprocess.DEVNULL,
|
|
1388
|
+
encoding="utf-8", errors="replace")
|
|
1389
|
+
_PS_CHECK[exe] = (p.returncode == 0, (p.stdout + p.stderr).strip())
|
|
1390
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
1391
|
+
_PS_CHECK[exe] = (False, str(e))
|
|
1392
|
+
return _PS_CHECK[exe]
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def explain_broken_powershell(info):
|
|
1396
|
+
exe = ps_exe() or "pwsh"
|
|
1397
|
+
lines = [l for l in info.splitlines() if l.strip()]
|
|
1398
|
+
shown = "\n".join(" " + l for l in (lines[:4] + ([" …"] if len(lines) > 4 else [])))
|
|
1399
|
+
print(f"\n {red('✘')} PowerShell is installed but does not start, so the modules can't be installed.")
|
|
1400
|
+
if shown:
|
|
1401
|
+
print(dim(shown))
|
|
1402
|
+
print(" Check it by hand: " + bold(f"{exe} -NoProfile -Command '$PSVersionTable.PSVersion'"))
|
|
1403
|
+
if not IS_WIN:
|
|
1404
|
+
print(" Useful details: " + bold("which -a pwsh; echo $TERM $HOME; ls -ld ~ ."))
|
|
1405
|
+
print(" azup's Linux PowerShell build needs ICU: " + bold("sudo apt install -y libicu-dev")
|
|
1406
|
+
+ dim(" (Fedora/RHEL: sudo dnf install -y libicu)"))
|
|
1407
|
+
print(" Other ways to install PowerShell: "
|
|
1408
|
+
"https://learn.microsoft.com/powershell/scripting/install/install-other-linux")
|
|
1409
|
+
print(" Fix it, then re-run azup – it will pick up where it left off.")
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def has_requirement(req):
|
|
1413
|
+
if not req:
|
|
1414
|
+
return True
|
|
1415
|
+
if req == "powershell":
|
|
1416
|
+
return ps_exe() is not None
|
|
1417
|
+
return have({"cask": "brew"}.get(req, req))
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def confirm(q, yes=False):
|
|
1421
|
+
if yes:
|
|
1422
|
+
return True
|
|
1423
|
+
if not (TTY and sys.stdin.isatty()):
|
|
1424
|
+
return False
|
|
1425
|
+
return input(f" {q} {dim('[Y/n]')} ").strip().lower() in ("", "y", "yes")
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def ensure_manager(mgr, dry, yes):
|
|
1429
|
+
mgr = {"cask": "brew"}.get(mgr, mgr)
|
|
1430
|
+
if have(mgr):
|
|
1431
|
+
return True
|
|
1432
|
+
if mgr == "brew" and not IS_WIN:
|
|
1433
|
+
print(yellow(" Homebrew is not installed. azup uses it for CLI tools on "
|
|
1434
|
+
+ OS_NAME + "."))
|
|
1435
|
+
if dry:
|
|
1436
|
+
print(dim(" would run the official Homebrew installer"))
|
|
1437
|
+
return True
|
|
1438
|
+
if not confirm("Install Homebrew now?", yes):
|
|
1439
|
+
return False
|
|
1440
|
+
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
|
1441
|
+
print(red(" Homebrew refuses to install as root. Run azup as a normal user that has sudo."))
|
|
1442
|
+
return False
|
|
1443
|
+
if not have("sudo"):
|
|
1444
|
+
print(red(" Homebrew needs sudo to create its install directory, and sudo is missing."))
|
|
1445
|
+
print(" Install sudo (or ask an admin), then re-run azup.")
|
|
1446
|
+
return False
|
|
1447
|
+
# The installer runs non-interactively, so authenticate sudo here where it can prompt.
|
|
1448
|
+
print(dim(" Homebrew needs administrator rights to create its directory – enter your password:"))
|
|
1449
|
+
if subprocess.call(["sudo", "-v"]) != 0:
|
|
1450
|
+
print(red(" sudo authentication failed."))
|
|
1451
|
+
return False
|
|
1452
|
+
rc = subprocess.call(["/bin/bash", "-c", 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL '
|
|
1453
|
+
'https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'])
|
|
1454
|
+
refresh_path()
|
|
1455
|
+
if rc != 0 or not have("brew"):
|
|
1456
|
+
if IS_LINUX:
|
|
1457
|
+
print(yellow(" Homebrew on Linux needs build tools. On Ubuntu/Debian:\n"
|
|
1458
|
+
" sudo apt update && sudo apt install -y build-essential procps curl file git"))
|
|
1459
|
+
return False
|
|
1460
|
+
return True
|
|
1461
|
+
if mgr == "winget" and IS_WIN and not dry:
|
|
1462
|
+
# Fresh Windows installs often have App Installer but haven't registered it for this user yet.
|
|
1463
|
+
print(dim(" winget not found – trying to register 'App Installer'…"))
|
|
1464
|
+
subprocess.call(["powershell", "-NoProfile", "-Command",
|
|
1465
|
+
"Add-AppxPackage -RegisterByFamilyName -MainPackage "
|
|
1466
|
+
"Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"],
|
|
1467
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
1468
|
+
refresh_path()
|
|
1469
|
+
if have("winget"):
|
|
1470
|
+
return True
|
|
1471
|
+
hints = {"winget": "winget ships with Windows 10/11 – update 'App Installer' in the Microsoft Store "
|
|
1472
|
+
"(or https://aka.ms/getwinget), then re-run azup.",
|
|
1473
|
+
"snap": "snap is not available – install PowerShell via "
|
|
1474
|
+
"https://learn.microsoft.com/powershell/scripting/install/installing-powershell-on-linux"}
|
|
1475
|
+
print(red(f" '{mgr}' not found. ") + hints.get(mgr, ""))
|
|
1476
|
+
return False
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def stream(cmd, dry):
|
|
1480
|
+
print(" " + dim("$ " + " ".join(cmd)))
|
|
1481
|
+
if dry:
|
|
1482
|
+
return True
|
|
1483
|
+
try:
|
|
1484
|
+
if cmd[0] == "brew" or SCHEDULED or is_quiet():
|
|
1485
|
+
rc, out = run_tee(cmd)
|
|
1486
|
+
if rc != 0 and "untrusted tap" in out and not is_quiet() and trust_tap(out):
|
|
1487
|
+
rc, out = run_tee(cmd) # retry once after trusting
|
|
1488
|
+
if rc == 0 and "not linked" in out:
|
|
1489
|
+
# e.g. the first attempt installed the keg but died before linking it
|
|
1490
|
+
for m in re.finditer(r"brew link (\S+)", out):
|
|
1491
|
+
print(" " + dim(f"$ brew link {m.group(1)}"))
|
|
1492
|
+
rc, _ = run_tee(["brew", "link", m.group(1)])
|
|
1493
|
+
return rc == 0
|
|
1494
|
+
return subprocess.call(resolve_exe(cmd)) == 0
|
|
1495
|
+
except OSError as e:
|
|
1496
|
+
print(red(f" {e}"))
|
|
1497
|
+
return False
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def run_tee(cmd):
|
|
1501
|
+
"""Run a command, echo its output live and also return it."""
|
|
1502
|
+
p = subprocess.Popen(resolve_exe(cmd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
1503
|
+
stdin=subprocess.DEVNULL if is_quiet() else None,
|
|
1504
|
+
text=True, encoding="utf-8", errors="replace")
|
|
1505
|
+
buf = []
|
|
1506
|
+
for line in p.stdout:
|
|
1507
|
+
sys.stdout.write(line)
|
|
1508
|
+
buf.append(line)
|
|
1509
|
+
p.wait()
|
|
1510
|
+
return p.returncode, "".join(buf)
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
TRUST_HINT = re.compile(r"`brew trust ((?:--\w+ )?[\w./@+-]+)`")
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def trust_tap(output):
|
|
1517
|
+
"""Homebrew refuses taps it does not trust. Show the fix; apply it if the user agrees."""
|
|
1518
|
+
m = TRUST_HINT.search(output)
|
|
1519
|
+
if not m:
|
|
1520
|
+
return False
|
|
1521
|
+
args = m.group(1).split()
|
|
1522
|
+
cmd = ["brew", "trust"] + args
|
|
1523
|
+
print(f"\n {yellow('▲')} Homebrew refuses to load from a tap you haven't trusted "
|
|
1524
|
+
f"({bold(args[-1])}).")
|
|
1525
|
+
print(dim(" Trusting lets Homebrew run that tap's formulae/casks. Only do this for sources you rely on."))
|
|
1526
|
+
if not confirm(f"Run `{' '.join(cmd)}` and retry?"):
|
|
1527
|
+
print(f" To fix it yourself run: {bold(' '.join(cmd))}")
|
|
1528
|
+
return False
|
|
1529
|
+
return subprocess.call(resolve_exe(cmd)) == 0
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
Action = Tuple[Tool, str]
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def execute(actions: List[Action], dry=False, yes=False, jobs=None):
|
|
1536
|
+
if not actions:
|
|
1537
|
+
print(f" {green('✔')} nothing to do.\n")
|
|
1538
|
+
return True
|
|
1539
|
+
rank = {"pkg": 0, "bicep": 2, "psmod": 1, "azext": 2, "azdext": 2}
|
|
1540
|
+
actions = sorted(actions, key=lambda a: rank[a[0].kind])
|
|
1541
|
+
for mgr in sorted({requires(t) for t, _ in actions if t.kind == "pkg"} - {""}):
|
|
1542
|
+
if not ensure_manager(mgr, dry, yes):
|
|
1543
|
+
print(red(f" cannot continue without {mgr}.\n"))
|
|
1544
|
+
return False
|
|
1545
|
+
if any(v == "update" and t.kind == "pkg" and spec(t)[0] in ("brew", "cask")
|
|
1546
|
+
for t, v in actions):
|
|
1547
|
+
print(f"\n {azure('▶')} {bold('Refreshing Homebrew index')}")
|
|
1548
|
+
stream(["brew", "update"], dry)
|
|
1549
|
+
_SHOWN.clear()
|
|
1550
|
+
n_jobs = resolve_jobs(jobs)
|
|
1551
|
+
if not dry and n_jobs > 1 and can_parallel(actions):
|
|
1552
|
+
results = run_parallel(actions, n_jobs, yes)
|
|
1553
|
+
else:
|
|
1554
|
+
results = run_sequential(actions, dry)
|
|
1555
|
+
print("\n " + bold("Summary"))
|
|
1556
|
+
for t, verb, ok, dt, note in results:
|
|
1557
|
+
print(f" {green('✔') if ok else red('✘')} {pad(t.name, 24)}{pad(dim(verb), 10)}"
|
|
1558
|
+
f"{dim(f'{dt:4.0f}s')} {red(note) if note else ''}")
|
|
1559
|
+
bad = [r for r in results if not r[2]]
|
|
1560
|
+
LAST_RESULTS[:] = results
|
|
1561
|
+
if not dry:
|
|
1562
|
+
cache_forget({r[0].key for r in results if r[2]})
|
|
1563
|
+
print(f"\n {progress(len(results) - len(bad), len(results))} "
|
|
1564
|
+
f"{dim(f'{len(results) - len(bad)}/{len(results)}')}\n")
|
|
1565
|
+
if SCHEDULED:
|
|
1566
|
+
print(f"{len(results) - len(bad)} ok, {len(bad)} failed")
|
|
1567
|
+
elif not dry:
|
|
1568
|
+
if bad:
|
|
1569
|
+
say(random.choice(OOPS), "sad")
|
|
1570
|
+
print(f"\n {red(str(len(bad)))} failed – scroll up for the error output.\n")
|
|
1571
|
+
else:
|
|
1572
|
+
confetti()
|
|
1573
|
+
say(random.choice(CHEERS))
|
|
1574
|
+
print()
|
|
1575
|
+
elif bad:
|
|
1576
|
+
print(f" {red(str(len(bad)))} failed.\n")
|
|
1577
|
+
return not bad
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
# ───────────────────────────── keyboard menus ─────────────────────────────
|
|
1581
|
+
def read_key():
|
|
1582
|
+
if IS_WIN:
|
|
1583
|
+
ch = msvcrt.getwch()
|
|
1584
|
+
if ch in ("\x00", "\xe0"):
|
|
1585
|
+
return {"H": b"\x1b[A", "P": b"\x1b[B"}.get(msvcrt.getwch(), b"")
|
|
1586
|
+
return {"\r": b"\r"}.get(ch, ch.encode())
|
|
1587
|
+
fd = sys.stdin.fileno()
|
|
1588
|
+
old = termios.tcgetattr(fd)
|
|
1589
|
+
try:
|
|
1590
|
+
tty.setraw(fd)
|
|
1591
|
+
ch = os.read(fd, 1)
|
|
1592
|
+
if ch == b"\x1b" and select.select([fd], [], [], 0.05)[0]:
|
|
1593
|
+
ch += os.read(fd, 2)
|
|
1594
|
+
return ch
|
|
1595
|
+
finally:
|
|
1596
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def interactive_ok():
|
|
1600
|
+
return TTY and sys.stdin.isatty() and (IS_WIN or termios is not None)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
Row = Tuple[str, str, bool] # label, hint, is_header
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def pick(title, rows: List[Row], multi=False, preselect=(), extra: Optional[Dict[bytes, Tuple[str, Callable]]] = None,
|
|
1607
|
+
start=0):
|
|
1608
|
+
"""Arrow-key menu. Returns an index (single), a sorted list (multi) or None if cancelled."""
|
|
1609
|
+
sel = set(preselect)
|
|
1610
|
+
n = len(rows)
|
|
1611
|
+
sel_idx = [i for i, r in enumerate(rows) if not r[2]]
|
|
1612
|
+
lw = min(36, max([24] + [plain_len(r[0]) + 2 for r in rows if not r[2]])) # label column width
|
|
1613
|
+
cur = start if start in sel_idx else sel_idx[0]
|
|
1614
|
+
extra = extra or {}
|
|
1615
|
+
keys = ["space toggle", "a all", "n none"] if multi else []
|
|
1616
|
+
keys += [f"{k.decode()} {v[0]}" for k, v in extra.items()]
|
|
1617
|
+
help_ = "↑/↓ move · " + " · ".join(keys + (["enter confirm"] if multi else ["enter select"]) + ["q cancel"])
|
|
1618
|
+
|
|
1619
|
+
def draw(first):
|
|
1620
|
+
if not first:
|
|
1621
|
+
sys.stdout.write(f"\x1b[{n + 2}A")
|
|
1622
|
+
out = [f"\x1b[K {bold(title)}"]
|
|
1623
|
+
for i, (lab, hint, head) in enumerate(rows):
|
|
1624
|
+
if head:
|
|
1625
|
+
out.append(f"\x1b[K {bold(azure(lab))}")
|
|
1626
|
+
continue
|
|
1627
|
+
ptr = azure("❯") if i == cur else " "
|
|
1628
|
+
box = (green("◉ ") if i in sel else dim("○ ")) if multi else ""
|
|
1629
|
+
out.append(f"\x1b[K {ptr} {box}{pad(bold(lab) if i == cur else lab, lw)} {hint}")
|
|
1630
|
+
out.append(f"\x1b[K {dim(help_)}")
|
|
1631
|
+
sys.stdout.write("\n".join(out) + "\n")
|
|
1632
|
+
sys.stdout.flush()
|
|
1633
|
+
|
|
1634
|
+
sys.stdout.write("\x1b[?25l")
|
|
1635
|
+
try:
|
|
1636
|
+
draw(True)
|
|
1637
|
+
while True:
|
|
1638
|
+
k = read_key()
|
|
1639
|
+
pos = sel_idx.index(cur)
|
|
1640
|
+
if k == b"\x03":
|
|
1641
|
+
raise KeyboardInterrupt
|
|
1642
|
+
if k in (b"q", b"\x1b"):
|
|
1643
|
+
return None
|
|
1644
|
+
if k in (b"\x1b[A", b"k"):
|
|
1645
|
+
cur = sel_idx[(pos - 1) % len(sel_idx)]
|
|
1646
|
+
elif k in (b"\x1b[B", b"j"):
|
|
1647
|
+
cur = sel_idx[(pos + 1) % len(sel_idx)]
|
|
1648
|
+
elif k == b" " and multi:
|
|
1649
|
+
sel ^= {cur}
|
|
1650
|
+
elif k == b"a" and multi:
|
|
1651
|
+
sel = set(sel_idx)
|
|
1652
|
+
elif k == b"n" and multi:
|
|
1653
|
+
sel = set()
|
|
1654
|
+
elif k in extra:
|
|
1655
|
+
sel = set(extra[k][1]())
|
|
1656
|
+
elif k in (b"\r", b"\n"):
|
|
1657
|
+
return sorted(sel) if multi else cur
|
|
1658
|
+
draw(False)
|
|
1659
|
+
finally:
|
|
1660
|
+
sys.stdout.write("\x1b[?25h")
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def pause():
|
|
1664
|
+
if interactive_ok():
|
|
1665
|
+
input(dim(" press enter to continue…"))
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
# ───────────────────────────── commands ─────────────────────────────
|
|
1669
|
+
def load(check=True):
|
|
1670
|
+
with Spinner("Inspecting your toolbox…", mascot=True):
|
|
1671
|
+
return detect(check)
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def resolve(names):
|
|
1675
|
+
out = []
|
|
1676
|
+
for n in names:
|
|
1677
|
+
t = (next((x for x in TOOLS if n in (x.key, x.pkg)), None) # exact case: az vs Az
|
|
1678
|
+
or (next((x for x in TOOLS if x.key == ALIASES[n.lower()]), None) if n.lower() in ALIASES else None)
|
|
1679
|
+
or BY_KEY.get(n.lower())
|
|
1680
|
+
or next((x for x in TOOLS if x.name.lower() == n.lower()), None))
|
|
1681
|
+
if not t:
|
|
1682
|
+
sys.exit(red(f" unknown tool '{n}'. Known: ") + ", ".join(x.key for x in TOOLS))
|
|
1683
|
+
out.append(t)
|
|
1684
|
+
return out
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def hint_for(s: State):
|
|
1688
|
+
if not s.installed:
|
|
1689
|
+
return dim("not installed")
|
|
1690
|
+
if not s.managed:
|
|
1691
|
+
return yellow(f"{s.version} · external (not managed)")
|
|
1692
|
+
if s.outdated:
|
|
1693
|
+
return yellow(f"{s.version} → {s.latest}")
|
|
1694
|
+
return green(f"{s.version} ✔")
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def select_flow(st=None, dry=False, yes=False, jobs=None):
|
|
1698
|
+
"""One checklist for everything: missing -> install, outdated -> update."""
|
|
1699
|
+
if not interactive_ok():
|
|
1700
|
+
print(red(" the checklist needs an interactive terminal."))
|
|
1701
|
+
return False
|
|
1702
|
+
st = st or load(True)
|
|
1703
|
+
rows: List[Row] = []
|
|
1704
|
+
idx: List[Optional[Tool]] = []
|
|
1705
|
+
last = None
|
|
1706
|
+
for t in TOOLS:
|
|
1707
|
+
if t.group != last:
|
|
1708
|
+
rows.append((t.group.upper(), "", True))
|
|
1709
|
+
idx.append(None)
|
|
1710
|
+
last = t.group
|
|
1711
|
+
rows.append((t.name, hint_for(st[t.key]) + dim(" " + t.desc), False))
|
|
1712
|
+
idx.append(t)
|
|
1713
|
+
pick_where = lambda f: [i for i, t in enumerate(idx) if t and f(st[t.key], t)]
|
|
1714
|
+
sel = pick("Select components to install / update", rows, True,
|
|
1715
|
+
pick_where(lambda s, t: s.outdated),
|
|
1716
|
+
{b"u": ("outdated", lambda: pick_where(lambda s, t: s.outdated)),
|
|
1717
|
+
b"m": ("missing", lambda: pick_where(lambda s, t: not s.installed)),
|
|
1718
|
+
b"c": ("core", lambda: pick_where(lambda s, t: t.core)),
|
|
1719
|
+
b"i": ("ai", lambda: pick_where(lambda s, t: t.core or t.ai))})
|
|
1720
|
+
if not sel:
|
|
1721
|
+
return True
|
|
1722
|
+
actions: List[Action] = []
|
|
1723
|
+
notes = []
|
|
1724
|
+
for i in sel:
|
|
1725
|
+
t, s = idx[i], st[idx[i].key]
|
|
1726
|
+
if not s.installed:
|
|
1727
|
+
actions.append((t, "install"))
|
|
1728
|
+
elif not s.managed:
|
|
1729
|
+
notes.append(f"{t.name}: installed outside azup – update it with its own installer")
|
|
1730
|
+
elif s.outdated or not s.latest:
|
|
1731
|
+
actions.append((t, "update"))
|
|
1732
|
+
else:
|
|
1733
|
+
notes.append(f"{t.name}: already up to date")
|
|
1734
|
+
print("\n " + bold("Plan"))
|
|
1735
|
+
for t, v in actions:
|
|
1736
|
+
print(f" {green('+') if v == 'install' else yellow('↑')} {v:8}{t.name}")
|
|
1737
|
+
for n in notes:
|
|
1738
|
+
print(f" {dim('·')} {dim(n)}")
|
|
1739
|
+
if not actions:
|
|
1740
|
+
print()
|
|
1741
|
+
return True
|
|
1742
|
+
print()
|
|
1743
|
+
if not proceed(actions, st, yes):
|
|
1744
|
+
print(" cancelled.\n")
|
|
1745
|
+
return True
|
|
1746
|
+
return execute(actions, dry, yes, jobs)
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def cmd_status(a):
|
|
1750
|
+
banner()
|
|
1751
|
+
print_table(load(not a.offline))
|
|
1752
|
+
return 0
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def cmd_select(a):
|
|
1756
|
+
return 0 if select_flow(dry=a.dry_run, yes=a.yes, jobs=a.jobs) else 1
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def cmd_install(a):
|
|
1760
|
+
if not (a.names or a.all or a.profile):
|
|
1761
|
+
return cmd_select(a)
|
|
1762
|
+
st = load(False)
|
|
1763
|
+
tools = [t for t in TOOLS if t.core] if a.profile == "core" else \
|
|
1764
|
+
[t for t in TOOLS if t.core or t.ai] if a.profile == "ai" else \
|
|
1765
|
+
list(TOOLS) if (a.all or a.profile == "full") else resolve(a.names)
|
|
1766
|
+
todo = [(t, "install") for t in tools if not st[t.key].installed]
|
|
1767
|
+
skipped = [t.name for t in tools if st[t.key].installed]
|
|
1768
|
+
if skipped:
|
|
1769
|
+
print(dim(" already installed: " + ", ".join(skipped)))
|
|
1770
|
+
return 0 if execute(todo, a.dry_run, a.yes, a.jobs) else 1
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def cmd_update(a):
|
|
1774
|
+
if getattr(a, "scheduled", False):
|
|
1775
|
+
return scheduled_update(a)
|
|
1776
|
+
st = load(True)
|
|
1777
|
+
tools = [t for t in resolve(a.names) if st[t.key].installed] if a.names \
|
|
1778
|
+
else [t for t in TOOLS if st[t.key].outdated]
|
|
1779
|
+
ext = [t.name for t in tools if not st[t.key].managed]
|
|
1780
|
+
if ext:
|
|
1781
|
+
print(yellow(" skipping (not managed by azup): " + ", ".join(ext)))
|
|
1782
|
+
acts = [(t, "update") for t in tools if st[t.key].managed]
|
|
1783
|
+
if getattr(a, "changes", False) and acts:
|
|
1784
|
+
print_changes([t for t, _ in acts], st)
|
|
1785
|
+
if not proceed([], st, a.yes):
|
|
1786
|
+
print(" cancelled.\n")
|
|
1787
|
+
return 0
|
|
1788
|
+
return 0 if execute(acts, a.dry_run, a.yes, a.jobs) else 1
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
def cmd_changes(a):
|
|
1792
|
+
st = load(True)
|
|
1793
|
+
if a.names:
|
|
1794
|
+
tools = [t for t in resolve(a.names) if st[t.key].installed]
|
|
1795
|
+
quiet = [t for t in tools if not st[t.key].outdated]
|
|
1796
|
+
tools = [t for t in tools if st[t.key].outdated]
|
|
1797
|
+
else:
|
|
1798
|
+
tools = [t for t in TOOLS if st[t.key].outdated]
|
|
1799
|
+
quiet = []
|
|
1800
|
+
if not tools and not quiet:
|
|
1801
|
+
print(f" {green('✔')} nothing is outdated – nothing new to read.\n")
|
|
1802
|
+
return 0
|
|
1803
|
+
print_changes(tools, st)
|
|
1804
|
+
if quiet:
|
|
1805
|
+
print(dim(" already on the latest version – showing the most recent release:"))
|
|
1806
|
+
print_changes(quiet, st, latest_only=True)
|
|
1807
|
+
return 0
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def cmd_login(a):
|
|
1811
|
+
pw = ps_exe() or "pwsh"
|
|
1812
|
+
tid = getattr(a, "tenant", None)
|
|
1813
|
+
steps = {"az": ["az", "login"] + (["--tenant", tid] if tid else []),
|
|
1814
|
+
"azd": ["azd", "auth", "login"] + (["--tenant-id", tid] if tid else []),
|
|
1815
|
+
"ps": [pw, "-NoProfile", "-Command", "Connect-AzAccount" + (f" -Tenant '{tid}'" if tid else "")],
|
|
1816
|
+
"graph": [pw, "-NoProfile", "-Command",
|
|
1817
|
+
"Connect-MgGraph " + (f"-TenantId '{tid}' " if tid else "") +
|
|
1818
|
+
"-Scopes 'User.Read.All','Group.Read.All','Directory.Read.All'"]}
|
|
1819
|
+
for k in (list(steps) if a.target == "all" else [a.target]):
|
|
1820
|
+
if not have(steps[k][0]):
|
|
1821
|
+
print(yellow(f" {steps[k][0]} not installed – skipping {k}"))
|
|
1822
|
+
continue
|
|
1823
|
+
print(f"\n {azure('▶')} {bold('Sign in: ' + k)}")
|
|
1824
|
+
stream(steps[k], False)
|
|
1825
|
+
return 0
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def cmd_doctor(a):
|
|
1829
|
+
refresh_path()
|
|
1830
|
+
banner()
|
|
1831
|
+
ok = lambda m: print(f" {green('✔')} {m}")
|
|
1832
|
+
warn = lambda m: print(f" {yellow('▲')} {m}")
|
|
1833
|
+
bad = lambda m: print(f" {red('✘')} {m}")
|
|
1834
|
+
print(" " + bold("Environment"))
|
|
1835
|
+
mgr = "winget" if IS_WIN else "brew"
|
|
1836
|
+
ok(f"{HOST_NAME[mgr]} found") if have(mgr) else bad(
|
|
1837
|
+
f"{HOST_NAME[mgr]} missing – azup offers to install it" if not IS_WIN else "winget missing")
|
|
1838
|
+
ps = ps_exe()
|
|
1839
|
+
if ps:
|
|
1840
|
+
rc, v = run([ps, "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"])
|
|
1841
|
+
good = vtuple(v) >= (7, 4)
|
|
1842
|
+
(ok if good else warn)(f"{ps} {v}" + ("" if good else " (PowerShell 7.4+ recommended → azup install pwsh)"))
|
|
1843
|
+
else:
|
|
1844
|
+
bad("PowerShell missing → azup install pwsh")
|
|
1845
|
+
print("\n " + bold("Sign-in"))
|
|
1846
|
+
if have("az"):
|
|
1847
|
+
with Spinner("checking az account…"):
|
|
1848
|
+
rc, out = run(["az", "account", "show", "-o", "json"], 30)
|
|
1849
|
+
if rc == 0:
|
|
1850
|
+
d = json.loads(out)
|
|
1851
|
+
ok(f"az: {d.get('user', {}).get('name', '?')} · {d['name']} · tenant {d['tenantId'][:8]}…")
|
|
1852
|
+
else:
|
|
1853
|
+
warn("az not signed in → azup login az")
|
|
1854
|
+
else:
|
|
1855
|
+
bad("Azure CLI missing → azup install az")
|
|
1856
|
+
if have("azd"):
|
|
1857
|
+
rc, _ = run(["azd", "auth", "login", "--check-status"], 20)
|
|
1858
|
+
ok("azd signed in") if rc == 0 else warn("azd not signed in → azup login azd")
|
|
1859
|
+
if ps:
|
|
1860
|
+
with Spinner("checking PowerShell sessions…"):
|
|
1861
|
+
rc, out = run([ps, "-NoProfile", "-Command",
|
|
1862
|
+
"$a='';try{$a=(Get-AzContext).Account.Id}catch{};"
|
|
1863
|
+
"$g='';try{$g=(Get-MgContext).Account}catch{};$a+'|'+$g"], 60)
|
|
1864
|
+
a_, _, g_ = out.partition("|")
|
|
1865
|
+
ok(f"Az PowerShell: {a_}") if a_ else warn("Az PowerShell not connected → azup login ps")
|
|
1866
|
+
ok(f"Graph: {g_}") if g_ else warn("Graph not connected → azup login graph")
|
|
1867
|
+
st = load(True)
|
|
1868
|
+
print("\n " + bold("Toolbox"))
|
|
1869
|
+
old = [t.name for t in TOOLS if st[t.key].outdated]
|
|
1870
|
+
warn(f"{len(old)} outdated: {', '.join(old)} → azup update") if old else ok(
|
|
1871
|
+
"everything installed is up to date")
|
|
1872
|
+
print()
|
|
1873
|
+
return 0
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
# ───────────────────────────── identity: whoami / ctx ─────────────────────────────
|
|
1877
|
+
def clip(text, w):
|
|
1878
|
+
return text if len(text) <= w else text[: w - 1] + "…"
|
|
1879
|
+
|
|
1880
|
+
|
|
1881
|
+
def gather_context():
|
|
1882
|
+
"""Who/where each tool is signed in as. Returns {tool: dict | None (not installed)}."""
|
|
1883
|
+
def az_ctx():
|
|
1884
|
+
if not have("az"):
|
|
1885
|
+
return None
|
|
1886
|
+
rc, out = run(["az", "account", "show", "-o", "json"], 30)
|
|
1887
|
+
if rc:
|
|
1888
|
+
return {"in": False}
|
|
1889
|
+
d = json.loads(out)
|
|
1890
|
+
return {"in": True, "account": d.get("user", {}).get("name", "?"), "sub": d["name"],
|
|
1891
|
+
"sub_id": d["id"], "tenant": d["tenantId"]}
|
|
1892
|
+
|
|
1893
|
+
def azd_ctx():
|
|
1894
|
+
if not have("azd"):
|
|
1895
|
+
return None
|
|
1896
|
+
rc, out = run(["azd", "auth", "login", "--check-status"], 20)
|
|
1897
|
+
m = re.search(r"as (\S+)", out)
|
|
1898
|
+
sub_id = ""
|
|
1899
|
+
rc2, o2 = run(["azd", "config", "get", "defaults.subscription"], 15)
|
|
1900
|
+
if rc2 == 0 and o2 and not o2.lstrip().startswith("{"):
|
|
1901
|
+
sub_id = o2.strip().strip('"')
|
|
1902
|
+
return {"in": rc == 0, "account": m.group(1) if m else "", "sub_id": sub_id}
|
|
1903
|
+
|
|
1904
|
+
def ps_ctx():
|
|
1905
|
+
if not ps_exe():
|
|
1906
|
+
return None, None
|
|
1907
|
+
body = ("$r=@{az=$null;mg=$null};"
|
|
1908
|
+
"try{$c=Get-AzContext;if($c){$r.az=@{account=$c.Account.Id;sub=$c.Subscription.Name;"
|
|
1909
|
+
"sub_id=$c.Subscription.Id;tenant=$c.Tenant.Id}}}catch{};"
|
|
1910
|
+
"try{$g=Get-MgContext;if($g){$r.mg=@{account=$g.Account;tenant=$g.TenantId}}}catch{};"
|
|
1911
|
+
"$r|ConvertTo-Json -Compress -Depth 4")
|
|
1912
|
+
rc, out = run(ps_cmd(body), 90)
|
|
1913
|
+
try:
|
|
1914
|
+
d = json.loads(out)
|
|
1915
|
+
except ValueError:
|
|
1916
|
+
d = {}
|
|
1917
|
+
z, g = d.get("az"), d.get("mg")
|
|
1918
|
+
return ({"in": bool(z), **(z or {})}, {"in": bool(g), **(g or {})})
|
|
1919
|
+
|
|
1920
|
+
with cf.ThreadPoolExecutor(3) as ex:
|
|
1921
|
+
fa, fd, fp = ex.submit(az_ctx), ex.submit(azd_ctx), ex.submit(ps_ctx)
|
|
1922
|
+
res = {"az": fa.result(), "azd": fd.result()}
|
|
1923
|
+
res["ps"], res["graph"] = fp.result()
|
|
1924
|
+
return res
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
def cmd_whoami(a):
|
|
1928
|
+
refresh_path()
|
|
1929
|
+
banner()
|
|
1930
|
+
with Spinner("asking every tool who you are…", mascot=True):
|
|
1931
|
+
c = gather_context()
|
|
1932
|
+
rows = [("Azure CLI", "az", "az login"), ("Azure Developer CLI", "azd", "azd auth login"),
|
|
1933
|
+
("Az PowerShell", "ps", "azup login ps"), ("Microsoft Graph", "graph", "azup login graph")]
|
|
1934
|
+
print(" " + bold("Who am I?") + "\n")
|
|
1935
|
+
for label, key, fix in rows:
|
|
1936
|
+
d = c.get(key)
|
|
1937
|
+
if d is None:
|
|
1938
|
+
print(f" {pad(bold(label), 24)}{dim('○ not installed')}")
|
|
1939
|
+
elif not d.get("in"):
|
|
1940
|
+
print(f" {pad(bold(label), 24)}{yellow('▲ not signed in')} {dim('→ ' + fix)}")
|
|
1941
|
+
else:
|
|
1942
|
+
bits = [d.get("account", "")]
|
|
1943
|
+
if d.get("sub"):
|
|
1944
|
+
bits.append(f"{clip(d['sub'], 34)} ({d['sub_id'][:8]})")
|
|
1945
|
+
elif d.get("sub_id"):
|
|
1946
|
+
bits.append(f"default sub {d['sub_id'][:8]}")
|
|
1947
|
+
if d.get("tenant"):
|
|
1948
|
+
bits.append(f"tenant {d['tenant'][:8]}")
|
|
1949
|
+
print(f" {pad(bold(label), 24)}{green('✔')} {dim(' · ').join(x for x in bits if x)}")
|
|
1950
|
+
az, ps, g = c.get("az") or {}, c.get("ps") or {}, c.get("graph") or {}
|
|
1951
|
+
drift = []
|
|
1952
|
+
if az.get("in") and ps.get("in") and az["sub_id"] != ps.get("sub_id"):
|
|
1953
|
+
drift.append(f"Az PowerShell is on '{clip(ps.get('sub', '?'), 30)}', az is on '{clip(az['sub'], 30)}'")
|
|
1954
|
+
if az.get("in") and g.get("in") and az["tenant"] != g.get("tenant"):
|
|
1955
|
+
drift.append("Microsoft Graph is signed in to a different tenant than az")
|
|
1956
|
+
print()
|
|
1957
|
+
if drift:
|
|
1958
|
+
for d in drift:
|
|
1959
|
+
print(f" {yellow('▲')} {d}")
|
|
1960
|
+
print(dim(" fix it with: azup ctx\n"))
|
|
1961
|
+
elif az.get("in"):
|
|
1962
|
+
print(f" {green('✔')} everything points at the same place\n")
|
|
1963
|
+
return 0
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def pick_filter(title, items, current=None, page=12):
|
|
1967
|
+
"""Type-to-filter list. items = [(label, hint, value)]. Returns a value or None."""
|
|
1968
|
+
if not interactive_ok():
|
|
1969
|
+
return None
|
|
1970
|
+
q, first, H = "", True, page + 3
|
|
1971
|
+
|
|
1972
|
+
def matches():
|
|
1973
|
+
words = q.lower().split()
|
|
1974
|
+
return [i for i, (l, h, v) in enumerate(items) if all(w in (l + " " + h).lower() for w in words)]
|
|
1975
|
+
|
|
1976
|
+
m = matches()
|
|
1977
|
+
cur = next((j for j, i in enumerate(m) if items[i][2] == current), 0)
|
|
1978
|
+
sys.stdout.write("\x1b[?25l")
|
|
1979
|
+
try:
|
|
1980
|
+
while True:
|
|
1981
|
+
if not first:
|
|
1982
|
+
sys.stdout.write(f"\x1b[{H}A")
|
|
1983
|
+
first = False
|
|
1984
|
+
start = max(0, min(cur - page // 2, len(m) - page)) if len(m) > page else 0
|
|
1985
|
+
lines = [f"\x1b[K {bold(title)}",
|
|
1986
|
+
f"\x1b[K {azure('›')} {q}{dim('▏')} {dim(f'{len(m)}/{len(items)}')}"]
|
|
1987
|
+
for r in range(page):
|
|
1988
|
+
j = start + r
|
|
1989
|
+
if j < len(m):
|
|
1990
|
+
lab, hint, _ = items[m[j]]
|
|
1991
|
+
ptr = azure("❯") if j == cur else " "
|
|
1992
|
+
lines.append(f"\x1b[K {ptr} {pad(bold(clip(lab, 40)) if j == cur else clip(lab, 40), 42)} {dim(hint)}")
|
|
1993
|
+
else:
|
|
1994
|
+
lines.append("\x1b[K")
|
|
1995
|
+
lines.append(f"\x1b[K {dim('type to filter · ↑/↓ move · enter select · esc cancel')}")
|
|
1996
|
+
sys.stdout.write("\n".join(lines) + "\n")
|
|
1997
|
+
sys.stdout.flush()
|
|
1998
|
+
k = read_key()
|
|
1999
|
+
if k == b"\x03":
|
|
2000
|
+
raise KeyboardInterrupt
|
|
2001
|
+
if k == b"\x1b":
|
|
2002
|
+
return None
|
|
2003
|
+
if k == b"\x1b[A":
|
|
2004
|
+
cur = max(0, cur - 1)
|
|
2005
|
+
elif k == b"\x1b[B":
|
|
2006
|
+
cur = min(max(len(m) - 1, 0), cur + 1)
|
|
2007
|
+
elif k in (b"\r", b"\n"):
|
|
2008
|
+
if m:
|
|
2009
|
+
return items[m[cur]][2]
|
|
2010
|
+
elif k in (b"\x7f", b"\x08"):
|
|
2011
|
+
q, m, cur = q[:-1], None, 0
|
|
2012
|
+
m = matches()
|
|
2013
|
+
elif len(k) == 1 and 32 <= k[0] < 127:
|
|
2014
|
+
q += k.decode()
|
|
2015
|
+
m, cur = matches(), 0
|
|
2016
|
+
finally:
|
|
2017
|
+
sys.stdout.write(f"\x1b[{H}A\x1b[J\x1b[?25h")
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
def set_context(sub):
|
|
2021
|
+
"""Point az, azd and Az PowerShell at one subscription. Graph is tenant-scoped: hint only."""
|
|
2022
|
+
sid, tid, name = sub["id"], sub["tenantId"], sub["name"]
|
|
2023
|
+
print(f"\n {azure('▶')} {bold('Switching to ' + clip(name, 50))} {dim(sid)}")
|
|
2024
|
+
ok = True
|
|
2025
|
+
rc, _ = run(["az", "account", "set", "--subscription", sid], 30)
|
|
2026
|
+
print(f" {green('✔') if rc == 0 else red('✘')} Azure CLI")
|
|
2027
|
+
ok &= rc == 0
|
|
2028
|
+
if have("azd"):
|
|
2029
|
+
rc, _ = run(["azd", "config", "set", "defaults.subscription", sid], 20)
|
|
2030
|
+
print(f" {green('✔') if rc == 0 else yellow('▲')} azd default subscription")
|
|
2031
|
+
if ps_exe():
|
|
2032
|
+
body = ("try{$c=Get-AzContext;if($c){Set-AzContext -SubscriptionId '%s' -TenantId '%s' "
|
|
2033
|
+
"-ErrorAction Stop|Out-Null;'ok'}else{'none'}}catch{'fail'}" % (sid, tid))
|
|
2034
|
+
with Spinner("switching Az PowerShell…"):
|
|
2035
|
+
rc, out = run(ps_cmd(body), 90)
|
|
2036
|
+
res = out.strip().splitlines()[-1] if out.strip() else "fail"
|
|
2037
|
+
if res == "ok":
|
|
2038
|
+
print(f" {green('✔')} Az PowerShell")
|
|
2039
|
+
elif res == "none":
|
|
2040
|
+
print(f" {dim('·')} Az PowerShell {dim('(not signed in – azup login ps)')}")
|
|
2041
|
+
else:
|
|
2042
|
+
print(f" {yellow('▲')} Az PowerShell {dim('(could not switch – try: azup login ps --tenant ' + tid + ')')}")
|
|
2043
|
+
print(f" {dim('·')} Microsoft Graph {dim('is tenant-scoped – if you changed tenant: azup login graph --tenant ' + tid)}\n")
|
|
2044
|
+
return ok
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def cmd_ctx(a):
|
|
2048
|
+
if not have("az"):
|
|
2049
|
+
print(red(" Azure CLI is not installed → azup install az"))
|
|
2050
|
+
return 1
|
|
2051
|
+
with Spinner("loading your subscriptions…", mascot=True):
|
|
2052
|
+
rc, out = run(["az", "account", "list", "--all", "-o", "json"], 90)
|
|
2053
|
+
try:
|
|
2054
|
+
subs = json.loads(out)
|
|
2055
|
+
except ValueError:
|
|
2056
|
+
subs = []
|
|
2057
|
+
if rc or not subs:
|
|
2058
|
+
print(yellow(" no subscriptions found – sign in first: azup login az"))
|
|
2059
|
+
return 1
|
|
2060
|
+
if not getattr(a, "all", False):
|
|
2061
|
+
subs = [x for x in subs if x.get("state") == "Enabled"] # disabled ones can't be used
|
|
2062
|
+
subs.sort(key=lambda x: x["name"].lower())
|
|
2063
|
+
cur = next((x["id"] for x in subs if x.get("isDefault")), None)
|
|
2064
|
+
if a.list:
|
|
2065
|
+
for x in subs:
|
|
2066
|
+
print(f" {green('●') if x['id'] == cur else ' '} {pad(clip(x['name'], 44), 46)}{dim(x['id'])} "
|
|
2067
|
+
f"{dim(x.get('tenantDisplayName') or x['tenantId'][:8])}")
|
|
2068
|
+
return 0
|
|
2069
|
+
if a.query:
|
|
2070
|
+
q = a.query.lower()
|
|
2071
|
+
hit = [x for x in subs if q == x["id"].lower() or q in x["name"].lower()]
|
|
2072
|
+
exact = [x for x in hit if q == x["id"].lower() or q == x["name"].lower()]
|
|
2073
|
+
hit = exact or hit
|
|
2074
|
+
if len(hit) != 1:
|
|
2075
|
+
print(red(f" '{a.query}' matches {len(hit)} subscriptions") + (":" if hit else "."))
|
|
2076
|
+
for x in hit[:15]:
|
|
2077
|
+
print(f" {x['name']} {dim(x['id'])}")
|
|
2078
|
+
return 1
|
|
2079
|
+
return 0 if set_context(hit[0]) else 1
|
|
2080
|
+
items = [(x["name"], f"{x['id'][:8]} · {x.get('tenantDisplayName') or x['tenantId'][:8]}"
|
|
2081
|
+
+ ("" if x.get("state") == "Enabled" else f" · {x.get('state')}"), x) for x in subs]
|
|
2082
|
+
chosen = pick_filter(f"Switch subscription ({len(subs)})", items, current=next((x for x in subs if x['id'] == cur), None))
|
|
2083
|
+
if not chosen:
|
|
2084
|
+
print(" cancelled.")
|
|
2085
|
+
return 0
|
|
2086
|
+
return 0 if set_context(chosen) else 1
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
|
|
2090
|
+
def cmd_self_update(a):
|
|
2091
|
+
here = os.path.abspath(__file__)
|
|
2092
|
+
low = os.path.realpath(here).lower().replace("\\", "/")
|
|
2093
|
+
if getattr(sys, "frozen", False):
|
|
2094
|
+
exe = os.path.abspath(sys.executable).lower().replace("\\", "/")
|
|
2095
|
+
hint = "winget upgrade saeid-adz.azup" if "/winget/" in exe else "download the latest azup.exe from the GitHub releases page"
|
|
2096
|
+
print(yellow(f" this azup.exe is a packaged build – update it with: {hint}"))
|
|
2097
|
+
return 0
|
|
2098
|
+
if "/cellar/azup/" in low: # azup's own Homebrew keg, not just Homebrew's Python
|
|
2099
|
+
print(yellow(" azup was installed with Homebrew – run: brew upgrade azup"))
|
|
2100
|
+
return 0
|
|
2101
|
+
if "site-packages" in low or "dist-packages" in low:
|
|
2102
|
+
print(yellow(" azup was installed with pip/pipx – run: pipx upgrade azup-cli"))
|
|
2103
|
+
return 0
|
|
2104
|
+
url = f"https://raw.githubusercontent.com/{REPO}/{BRANCH}/azup.py"
|
|
2105
|
+
try:
|
|
2106
|
+
with Spinner("checking GitHub…"):
|
|
2107
|
+
data = urllib.request.urlopen(url, timeout=20).read().decode("utf-8")
|
|
2108
|
+
except Exception as e:
|
|
2109
|
+
print(red(f" could not download {url}: {e}"))
|
|
2110
|
+
return 1
|
|
2111
|
+
m = re.search(r'^VERSION = "([^"]+)"', data, re.M)
|
|
2112
|
+
if not m or "def main" not in data:
|
|
2113
|
+
print(red(" downloaded file does not look like azup – not replacing."))
|
|
2114
|
+
return 1
|
|
2115
|
+
if vtuple(m.group(1)) <= vtuple(VERSION):
|
|
2116
|
+
print(f" {green('✔')} azup {VERSION} is the latest.")
|
|
2117
|
+
return 0
|
|
2118
|
+
with open(here, "w", encoding="utf-8", newline="\n") as f:
|
|
2119
|
+
f.write(data)
|
|
2120
|
+
print(f" {green('✔')} updated azup {VERSION} → {m.group(1)}")
|
|
2121
|
+
return 0
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
# ───────────────────────────── nudge & scheduled updates ─────────────────────────────
|
|
2125
|
+
AZUP_DIR = os.path.expanduser("~/.azup")
|
|
2126
|
+
SCHEDULED = False # True while `azup update --scheduled` runs unattended
|
|
2127
|
+
LAST_RESULTS: list = [] # (tool, verb, ok, seconds, note) of the last execute()
|
|
2128
|
+
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
|
2129
|
+
SCHED_LABEL, TASK_NAME = "io.github.azup.update", "azup-update"
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
def _p(*parts):
|
|
2133
|
+
return os.path.join(AZUP_DIR, *parts)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def jload(name, default=None):
|
|
2137
|
+
try:
|
|
2138
|
+
with open(_p(name), encoding="utf-8") as f:
|
|
2139
|
+
return json.load(f)
|
|
2140
|
+
except (OSError, ValueError):
|
|
2141
|
+
return default
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
def jsave(name, obj):
|
|
2145
|
+
try:
|
|
2146
|
+
os.makedirs(AZUP_DIR, exist_ok=True)
|
|
2147
|
+
tmp = _p(name + ".tmp")
|
|
2148
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
2149
|
+
json.dump(obj, f, indent=1)
|
|
2150
|
+
os.replace(tmp, _p(name))
|
|
2151
|
+
except OSError:
|
|
2152
|
+
pass
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
def cfg_get():
|
|
2156
|
+
return jload("config.json") or {}
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
def cfg_set(section, **kw):
|
|
2160
|
+
c = cfg_get()
|
|
2161
|
+
c.setdefault(section, {}).update(kw)
|
|
2162
|
+
jsave("config.json", c)
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def parse_dur(txt):
|
|
2166
|
+
m = re.fullmatch(r"(\d+)\s*([hdw])", (txt or "").strip().lower())
|
|
2167
|
+
return int(m.group(1)) * {"h": 3600, "d": 86400, "w": 604800}[m.group(2)] if m else None
|
|
2168
|
+
|
|
2169
|
+
|
|
2170
|
+
def ago(ts):
|
|
2171
|
+
d = max(0, time.time() - ts)
|
|
2172
|
+
return "just now" if d < 90 else f"{int(d // 60)}m ago" if d < 5400 else \
|
|
2173
|
+
f"{int(d // 3600)}h ago" if d < 172800 else f"{int(d // 86400)}d ago"
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
class Tee:
|
|
2177
|
+
def __init__(self, *streams):
|
|
2178
|
+
self.streams = streams
|
|
2179
|
+
|
|
2180
|
+
def write(self, x):
|
|
2181
|
+
for s in self.streams:
|
|
2182
|
+
s.write(x)
|
|
2183
|
+
return len(x)
|
|
2184
|
+
|
|
2185
|
+
def flush(self):
|
|
2186
|
+
for s in self.streams:
|
|
2187
|
+
s.flush()
|
|
2188
|
+
|
|
2189
|
+
def isatty(self):
|
|
2190
|
+
return False
|
|
2191
|
+
|
|
2192
|
+
def reconfigure(self, **kw):
|
|
2193
|
+
pass
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
# ── cache of "what is outdated", refreshed in the background ──
|
|
2197
|
+
def cache_write(st):
|
|
2198
|
+
out = [{"key": t.key, "name": t.name, "version": st[t.key].version, "latest": st[t.key].latest}
|
|
2199
|
+
for t in TOOLS if st[t.key].outdated and st[t.key].managed]
|
|
2200
|
+
jsave("state.json", {"checked": time.time(), "refreshing": 0, "outdated": out})
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
def cache_forget(keys):
|
|
2204
|
+
s = jload("state.json")
|
|
2205
|
+
if s and keys:
|
|
2206
|
+
s["outdated"] = [o for o in s.get("outdated", []) if o["key"] not in keys]
|
|
2207
|
+
jsave("state.json", s)
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
def spawn_refresh():
|
|
2211
|
+
s = jload("state.json") or {}
|
|
2212
|
+
if time.time() - s.get("refreshing", 0) < 600: # one refresh at a time
|
|
2213
|
+
return
|
|
2214
|
+
s["refreshing"] = time.time()
|
|
2215
|
+
jsave("state.json", s)
|
|
2216
|
+
kw = {"creationflags": 0x00000008 | 0x08000000} if IS_WIN else {"start_new_session": True}
|
|
2217
|
+
env = None
|
|
2218
|
+
if getattr(sys, "frozen", False):
|
|
2219
|
+
# one-file PyInstaller: give the detached child its own extraction dir, since the parent
|
|
2220
|
+
# deletes its own on exit
|
|
2221
|
+
env = dict(os.environ, PYINSTALLER_RESET_ENVIRONMENT="1")
|
|
2222
|
+
try:
|
|
2223
|
+
subprocess.Popen(self_cmd() + ["nudge", "refresh"],
|
|
2224
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
2225
|
+
close_fds=True, env=env, **kw)
|
|
2226
|
+
except OSError:
|
|
2227
|
+
pass
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
def nudge_show():
|
|
2231
|
+
"""Called from your shell profile: at most one short line, from the cache. Never slow, never noisy."""
|
|
2232
|
+
if not TTY:
|
|
2233
|
+
return
|
|
2234
|
+
now = time.time()
|
|
2235
|
+
cfg = cfg_get().get("nudge", {})
|
|
2236
|
+
st = jload("state.json") or {}
|
|
2237
|
+
if now - st.get("checked", 0) > 12 * 3600:
|
|
2238
|
+
spawn_refresh()
|
|
2239
|
+
lr = jload("last-run.json")
|
|
2240
|
+
if lr and not lr.get("seen") and (lr.get("updated") or lr.get("failed")):
|
|
2241
|
+
bits = []
|
|
2242
|
+
if lr.get("updated"):
|
|
2243
|
+
bits.append(green(f"updated {len(lr['updated'])}") + dim(f" ({', '.join(lr['updated'][:3])}"
|
|
2244
|
+
+ (f" +{len(lr['updated']) - 3}" if len(lr["updated"]) > 3 else "") + ")"))
|
|
2245
|
+
if lr.get("failed"):
|
|
2246
|
+
bits.append(red(f"{len(lr['failed'])} failed") + dim(f" ({', '.join(lr['failed'][:3])})"))
|
|
2247
|
+
print(f" {azure('▟▙')} {bold('azup')} auto-update {ago(lr.get('at', now))}: " + " · ".join(bits)
|
|
2248
|
+
+ dim(f" log: {lr.get('log', '')}"))
|
|
2249
|
+
lr["seen"] = True
|
|
2250
|
+
jsave("last-run.json", lr)
|
|
2251
|
+
out = st.get("outdated") or []
|
|
2252
|
+
if out and now >= cfg.get("snooze_until", 0) and now - cfg.get("last_shown", 0) >= cfg.get("every_h", 24) * 3600:
|
|
2253
|
+
names = [o["name"] for o in out]
|
|
2254
|
+
shown = ", ".join(names[:3]) + (f" +{len(names) - 3}" if len(names) > 3 else "")
|
|
2255
|
+
print(f" {azure('▟▙')} {bold('azup')} {yellow(f'{len(out)} tool' + ('s' if len(out) != 1 else '') + ' outdated')} "
|
|
2256
|
+
f"{dim('(' + shown + ')')} → {bold('azup update')} {dim('· azup changes · azup nudge snooze 7d')}")
|
|
2257
|
+
cfg["last_shown"] = now
|
|
2258
|
+
cfg_set("nudge", **cfg)
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
# ── shell hooks (a clearly marked block you can remove with `azup nudge disable`) ──
|
|
2262
|
+
NUDGE_START, NUDGE_END = "# >>> azup nudge >>>", "# <<< azup nudge <<<"
|
|
2263
|
+
SH_BODY = "command -v azup >/dev/null 2>&1 && azup nudge"
|
|
2264
|
+
PS_BODY = "if (Get-Command azup -ErrorAction SilentlyContinue) { azup nudge }"
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
def ps_profile_paths():
|
|
2268
|
+
out = []
|
|
2269
|
+
for exe in (("pwsh", "powershell") if IS_WIN else ("pwsh",)):
|
|
2270
|
+
if have(exe):
|
|
2271
|
+
rc, o = run([exe, "-NoProfile", "-Command", "$PROFILE.CurrentUserCurrentHost"], 30)
|
|
2272
|
+
if rc == 0 and o.strip():
|
|
2273
|
+
out.append((exe, o.strip().splitlines()[-1]))
|
|
2274
|
+
return out
|
|
2275
|
+
|
|
2276
|
+
|
|
2277
|
+
def shell_targets(which=None):
|
|
2278
|
+
t = []
|
|
2279
|
+
sh = "" if IS_WIN else os.path.basename(os.environ.get("SHELL", ""))
|
|
2280
|
+
if which == "zsh" or (which is None and sh == "zsh"):
|
|
2281
|
+
t.append(("zsh", os.path.expanduser("~/.zshrc"), SH_BODY))
|
|
2282
|
+
if which == "bash" or (which is None and sh == "bash"):
|
|
2283
|
+
t.append(("bash", os.path.expanduser("~/.bash_profile" if IS_MAC else "~/.bashrc"), SH_BODY))
|
|
2284
|
+
if which in (None, "pwsh"):
|
|
2285
|
+
t += [(exe, path, PS_BODY) for exe, path in ps_profile_paths()]
|
|
2286
|
+
return t
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def read_text(path):
|
|
2290
|
+
with open(path, encoding="utf-8") as f:
|
|
2291
|
+
return f.read()
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
def write_text(path, text):
|
|
2295
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
2296
|
+
f.write(text)
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
def add_block(path, body):
|
|
2300
|
+
try:
|
|
2301
|
+
text = read_text(path)
|
|
2302
|
+
except OSError:
|
|
2303
|
+
text = ""
|
|
2304
|
+
if NUDGE_START in text:
|
|
2305
|
+
return False
|
|
2306
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
2307
|
+
with open(path, "a", encoding="utf-8", newline="\n") as f:
|
|
2308
|
+
f.write(("\n" if text and not text.endswith("\n") else "") + f"{NUDGE_START}\n{body}\n{NUDGE_END}\n")
|
|
2309
|
+
return True
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
def remove_block(path):
|
|
2313
|
+
try:
|
|
2314
|
+
text = read_text(path)
|
|
2315
|
+
except OSError:
|
|
2316
|
+
return False
|
|
2317
|
+
new = re.sub(rf"\n?{re.escape(NUDGE_START)}.*?{re.escape(NUDGE_END)}\n?", "\n", text, flags=re.S)
|
|
2318
|
+
if new == text:
|
|
2319
|
+
return False
|
|
2320
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
2321
|
+
f.write(new)
|
|
2322
|
+
return True
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
def nudge_enable(a):
|
|
2326
|
+
targets = shell_targets(getattr(a, "shell", None))
|
|
2327
|
+
if not targets:
|
|
2328
|
+
print(yellow(" couldn't tell which shell you use. Pick one: azup nudge enable --shell zsh|bash|pwsh"))
|
|
2329
|
+
return 1
|
|
2330
|
+
print(f"\n {bold('Terminal nudge')} – when you open a terminal azup prints one short line if tools are\n"
|
|
2331
|
+
f" outdated (at most once a day). It reads a small cache that a background job refreshes with the\n"
|
|
2332
|
+
f" same package-manager lookups as `azup status`. Nothing is installed or changed automatically.\n")
|
|
2333
|
+
for label, path, body in targets:
|
|
2334
|
+
print(f" {azure('•')} {label}: {path}")
|
|
2335
|
+
print()
|
|
2336
|
+
if not confirm("Add the one-line hook to these files?", getattr(a, "yes", False)):
|
|
2337
|
+
print(" cancelled.\n")
|
|
2338
|
+
return 0
|
|
2339
|
+
for label, path, body in targets:
|
|
2340
|
+
print(f" {green('✔') if add_block(path, body) else dim('·')} {label}")
|
|
2341
|
+
cfg_set("nudge", enabled=True)
|
|
2342
|
+
spawn_refresh()
|
|
2343
|
+
print(f"\n Done. Open a new terminal. Remove it anytime with {bold('azup nudge disable')}.\n")
|
|
2344
|
+
return 0
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
def nudge_disable(a):
|
|
2348
|
+
paths = {os.path.expanduser(p) for p in ("~/.zshrc", "~/.bashrc", "~/.bash_profile", "~/.profile")}
|
|
2349
|
+
paths |= {path for _, path in ps_profile_paths()}
|
|
2350
|
+
gone = [p for p in sorted(paths) if remove_block(p)]
|
|
2351
|
+
cfg_set("nudge", enabled=False)
|
|
2352
|
+
for p in gone:
|
|
2353
|
+
print(f" {green('✔')} removed from {p}")
|
|
2354
|
+
print(f" {green('✔')} nudge is off.\n")
|
|
2355
|
+
return 0
|
|
2356
|
+
|
|
2357
|
+
|
|
2358
|
+
def nudge_status(a):
|
|
2359
|
+
cfg, st = cfg_get().get("nudge", {}), jload("state.json") or {}
|
|
2360
|
+
hooked = [p for _, p, _ in shell_targets() if NUDGE_START in (read_text(p) if os.path.exists(p) else "")]
|
|
2361
|
+
print(f"\n {bold('Terminal nudge')}: {green('on') if cfg.get('enabled') else dim('off')}"
|
|
2362
|
+
+ (f" {dim('in ' + ', '.join(hooked))}" if hooked else ""))
|
|
2363
|
+
if st.get("checked"):
|
|
2364
|
+
n = len(st.get("outdated") or [])
|
|
2365
|
+
print(f" last check: {ago(st['checked'])} · {n} outdated")
|
|
2366
|
+
else:
|
|
2367
|
+
print(dim(" no check yet (a background refresh runs when you open a terminal)"))
|
|
2368
|
+
if cfg.get("snooze_until", 0) > time.time():
|
|
2369
|
+
print(f" snoozed for another {int((cfg['snooze_until'] - time.time()) // 3600)}h")
|
|
2370
|
+
print()
|
|
2371
|
+
return 0
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
def cmd_nudge(a):
|
|
2375
|
+
act = a.action
|
|
2376
|
+
try:
|
|
2377
|
+
if act == "show":
|
|
2378
|
+
nudge_show()
|
|
2379
|
+
elif act == "refresh":
|
|
2380
|
+
cache_write(detect(True))
|
|
2381
|
+
elif act == "enable":
|
|
2382
|
+
return nudge_enable(a)
|
|
2383
|
+
elif act == "disable":
|
|
2384
|
+
return nudge_disable(a)
|
|
2385
|
+
elif act == "status":
|
|
2386
|
+
return nudge_status(a)
|
|
2387
|
+
elif act == "snooze":
|
|
2388
|
+
secs = parse_dur(a.arg or "7d")
|
|
2389
|
+
if not secs:
|
|
2390
|
+
print(red(" use a duration like 12h, 7d or 2w"))
|
|
2391
|
+
return 1
|
|
2392
|
+
cfg_set("nudge", snooze_until=time.time() + secs)
|
|
2393
|
+
print(f" {green('✔')} quiet for {a.arg or '7d'}")
|
|
2394
|
+
except Exception:
|
|
2395
|
+
if act != "show":
|
|
2396
|
+
raise
|
|
2397
|
+
return 0
|
|
2398
|
+
|
|
2399
|
+
|
|
2400
|
+
# ── scheduled updates: launchd (macOS) · systemd or cron (Linux) · Task Scheduler (Windows) ──
|
|
2401
|
+
def self_cmd():
|
|
2402
|
+
"""Command that re-launches azup: the exe itself when frozen, else python + this script."""
|
|
2403
|
+
if getattr(sys, "frozen", False):
|
|
2404
|
+
return [sys.executable]
|
|
2405
|
+
return [sys.executable, os.path.abspath(__file__)]
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def sched_cmd():
|
|
2409
|
+
cmd = self_cmd()
|
|
2410
|
+
if IS_WIN and not getattr(sys, "frozen", False):
|
|
2411
|
+
pw = os.path.join(os.path.dirname(cmd[0]), "pythonw.exe") # no console window flashing
|
|
2412
|
+
cmd[0] = pw if os.path.exists(pw) else cmd[0]
|
|
2413
|
+
return cmd + ["update", "--scheduled"]
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
def sched_path_env():
|
|
2417
|
+
return ":".join(["/opt/homebrew/bin", "/usr/local/bin", "/home/linuxbrew/.linuxbrew/bin",
|
|
2418
|
+
os.path.expanduser("~/.local/bin"), "/usr/bin", "/bin", "/usr/sbin", "/sbin"])
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
def parse_at(txt):
|
|
2422
|
+
m = re.fullmatch(r"(\d{1,2}):(\d{2})", txt or "")
|
|
2423
|
+
if not m or int(m.group(1)) > 23 or int(m.group(2)) > 59:
|
|
2424
|
+
raise ValueError("use --at HH:MM (24h), e.g. 09:00")
|
|
2425
|
+
return int(m.group(1)), int(m.group(2))
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def launchd_plist(cmd, freq, day, hh, mm):
|
|
2429
|
+
cal = {"Hour": hh, "Minute": mm}
|
|
2430
|
+
if freq == "weekly":
|
|
2431
|
+
cal["Weekday"] = (DAYS.index(day) + 1) % 7 # launchd: Sunday = 0
|
|
2432
|
+
return plistlib.dumps({"Label": SCHED_LABEL, "ProgramArguments": cmd, "StartCalendarInterval": cal,
|
|
2433
|
+
"EnvironmentVariables": {"PATH": sched_path_env()},
|
|
2434
|
+
"StandardOutPath": _p("logs", "launchd.log"), "StandardErrorPath": _p("logs", "launchd.log"),
|
|
2435
|
+
"RunAtLoad": False, "ProcessType": "Background"}).decode()
|
|
2436
|
+
|
|
2437
|
+
|
|
2438
|
+
def systemd_units(cmd, freq, day, hh, mm):
|
|
2439
|
+
service = ("[Unit]\nDescription=azup: update the Azure toolbox\n\n[Service]\nType=oneshot\n"
|
|
2440
|
+
f"Environment=PATH={sched_path_env()}\nExecStart={shlex.join(cmd)}\n")
|
|
2441
|
+
cal = (f"{day.capitalize()} " if freq == "weekly" else "") + f"*-*-* {hh:02d}:{mm:02d}:00"
|
|
2442
|
+
timer = (f"[Unit]\nDescription=azup scheduled update ({freq})\n\n[Timer]\nOnCalendar={cal}\n"
|
|
2443
|
+
"Persistent=true\n\n[Install]\nWantedBy=timers.target\n")
|
|
2444
|
+
return service, timer
|
|
2445
|
+
|
|
2446
|
+
|
|
2447
|
+
def cron_line(cmd, freq, day, hh, mm):
|
|
2448
|
+
dow = str((DAYS.index(day) + 1) % 7) if freq == "weekly" else "*"
|
|
2449
|
+
log = _p("logs", "cron.log")
|
|
2450
|
+
return (f"{mm} {hh} * * {dow} PATH={shlex.quote(sched_path_env())} {shlex.join(cmd)} "
|
|
2451
|
+
f">> {shlex.quote(log)} 2>&1 # {TASK_NAME}")
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def schtasks_create(cmd, freq, day, hh, mm):
|
|
2455
|
+
args = ["schtasks", "/Create", "/TN", TASK_NAME, "/SC", "WEEKLY" if freq == "weekly" else "DAILY",
|
|
2456
|
+
"/ST", f"{hh:02d}:{mm:02d}", "/TR", subprocess.list2cmdline(cmd), "/F"]
|
|
2457
|
+
if freq == "weekly":
|
|
2458
|
+
args[6:6] = ["/D", day.upper()]
|
|
2459
|
+
return args
|
|
2460
|
+
|
|
2461
|
+
|
|
2462
|
+
def systemd_ok():
|
|
2463
|
+
return have("systemctl") and run(["systemctl", "--user", "show-environment"], 15)[0] == 0
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def sched_enable(freq, day, at, dry):
|
|
2467
|
+
hh, mm = parse_at(at)
|
|
2468
|
+
cmd = sched_cmd()
|
|
2469
|
+
os.makedirs(_p("logs"), exist_ok=True)
|
|
2470
|
+
|
|
2471
|
+
def show(title, text):
|
|
2472
|
+
print(f"\n {dim(title)}\n" + "\n".join(" " + l for l in text.rstrip().splitlines()))
|
|
2473
|
+
|
|
2474
|
+
if IS_MAC:
|
|
2475
|
+
path = os.path.expanduser(f"~/Library/LaunchAgents/{SCHED_LABEL}.plist")
|
|
2476
|
+
text = launchd_plist(cmd, freq, day, hh, mm)
|
|
2477
|
+
if dry:
|
|
2478
|
+
show(f"would write {path}", text)
|
|
2479
|
+
return True
|
|
2480
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
2481
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
2482
|
+
f.write(text)
|
|
2483
|
+
dom = f"gui/{os.getuid()}"
|
|
2484
|
+
run(["launchctl", "bootout", f"{dom}/{SCHED_LABEL}"], 20)
|
|
2485
|
+
return run(["launchctl", "bootstrap", dom, path], 20)[0] == 0
|
|
2486
|
+
if IS_WIN:
|
|
2487
|
+
args = schtasks_create(cmd, freq, day, hh, mm)
|
|
2488
|
+
if dry:
|
|
2489
|
+
show("would run", " ".join(args))
|
|
2490
|
+
return True
|
|
2491
|
+
return run(args, 30)[0] == 0
|
|
2492
|
+
if systemd_ok():
|
|
2493
|
+
svc, tmr = systemd_units(cmd, freq, day, hh, mm)
|
|
2494
|
+
d = os.path.expanduser("~/.config/systemd/user")
|
|
2495
|
+
if dry:
|
|
2496
|
+
show(f"would write {d}/azup-update.service", svc)
|
|
2497
|
+
show(f"would write {d}/azup-update.timer", tmr)
|
|
2498
|
+
return True
|
|
2499
|
+
os.makedirs(d, exist_ok=True)
|
|
2500
|
+
write_text(os.path.join(d, "azup-update.service"), svc)
|
|
2501
|
+
write_text(os.path.join(d, "azup-update.timer"), tmr)
|
|
2502
|
+
run(["systemctl", "--user", "daemon-reload"], 20)
|
|
2503
|
+
return run(["systemctl", "--user", "enable", "--now", "azup-update.timer"], 20)[0] == 0
|
|
2504
|
+
if have("crontab"):
|
|
2505
|
+
line = cron_line(cmd, freq, day, hh, mm)
|
|
2506
|
+
if dry:
|
|
2507
|
+
show("would add to your crontab", line)
|
|
2508
|
+
return True
|
|
2509
|
+
rc, cur = run(["crontab", "-l"], 15)
|
|
2510
|
+
keep = [l for l in (cur.splitlines() if rc == 0 else []) if TASK_NAME not in l]
|
|
2511
|
+
p = subprocess.run(["crontab", "-"], input="\n".join(keep + [line]) + "\n", text=True)
|
|
2512
|
+
return p.returncode == 0
|
|
2513
|
+
print(red(" neither systemd (user) nor cron is available on this machine."))
|
|
2514
|
+
return False
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
def sched_disable():
|
|
2518
|
+
if IS_MAC:
|
|
2519
|
+
path = os.path.expanduser(f"~/Library/LaunchAgents/{SCHED_LABEL}.plist")
|
|
2520
|
+
run(["launchctl", "bootout", f"gui/{os.getuid()}/{SCHED_LABEL}"], 20)
|
|
2521
|
+
if os.path.exists(path):
|
|
2522
|
+
os.remove(path)
|
|
2523
|
+
return True
|
|
2524
|
+
if IS_WIN:
|
|
2525
|
+
return run(["schtasks", "/Delete", "/TN", TASK_NAME, "/F"], 30)[0] == 0
|
|
2526
|
+
d = os.path.expanduser("~/.config/systemd/user")
|
|
2527
|
+
if have("systemctl"):
|
|
2528
|
+
run(["systemctl", "--user", "disable", "--now", "azup-update.timer"], 20)
|
|
2529
|
+
for f in ("azup-update.service", "azup-update.timer"):
|
|
2530
|
+
if os.path.exists(os.path.join(d, f)):
|
|
2531
|
+
os.remove(os.path.join(d, f))
|
|
2532
|
+
if have("crontab"):
|
|
2533
|
+
rc, cur = run(["crontab", "-l"], 15)
|
|
2534
|
+
if rc == 0 and TASK_NAME in cur:
|
|
2535
|
+
subprocess.run(["crontab", "-"], input="\n".join(l for l in cur.splitlines() if TASK_NAME not in l) + "\n", text=True)
|
|
2536
|
+
return True
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def sched_active():
|
|
2540
|
+
if IS_MAC:
|
|
2541
|
+
return run(["launchctl", "print", f"gui/{os.getuid()}/{SCHED_LABEL}"], 20)[0] == 0
|
|
2542
|
+
if IS_WIN:
|
|
2543
|
+
return run(["schtasks", "/Query", "/TN", TASK_NAME], 30)[0] == 0
|
|
2544
|
+
if have("systemctl") and run(["systemctl", "--user", "is-enabled", "azup-update.timer"], 15)[0] == 0:
|
|
2545
|
+
return True
|
|
2546
|
+
return have("crontab") and TASK_NAME in run(["crontab", "-l"], 15)[1]
|
|
2547
|
+
|
|
2548
|
+
|
|
2549
|
+
def cmd_schedule(a):
|
|
2550
|
+
act = a.action
|
|
2551
|
+
if act == "run":
|
|
2552
|
+
return cmd_update(argparse.Namespace(names=[], dry_run=False, yes=True, changes=False, scheduled=True))
|
|
2553
|
+
if act == "status":
|
|
2554
|
+
on, cfg = sched_active(), cfg_get().get("schedule", {})
|
|
2555
|
+
print(f"\n {bold('Scheduled update')}: {green('on') if on else dim('off')}"
|
|
2556
|
+
+ (f" {dim(cfg.get('freq', '') + ' ' + (cfg.get('day', '') if cfg.get('freq') == 'weekly' else '') + ' ' + cfg.get('at', ''))}" if on and cfg else ""))
|
|
2557
|
+
lr = jload("last-run.json")
|
|
2558
|
+
if lr:
|
|
2559
|
+
print(f" last run: {ago(lr['at'])} · {len(lr.get('updated', []))} updated, {len(lr.get('failed', []))} failed"
|
|
2560
|
+
+ (f", {len(lr.get('skipped', []))} skipped" if lr.get("skipped") else "") + f" {dim(lr.get('log', ''))}")
|
|
2561
|
+
print()
|
|
2562
|
+
return 0
|
|
2563
|
+
if act == "disable":
|
|
2564
|
+
sched_disable()
|
|
2565
|
+
cfg_set("schedule", enabled=False)
|
|
2566
|
+
print(f" {green('✔')} scheduled update is off.\n")
|
|
2567
|
+
return 0
|
|
2568
|
+
freq = "daily" if a.daily else "weekly"
|
|
2569
|
+
try:
|
|
2570
|
+
ok = sched_enable(freq, a.day, a.at, a.dry_run)
|
|
2571
|
+
except ValueError as e:
|
|
2572
|
+
print(red(f" {e}"))
|
|
2573
|
+
return 1
|
|
2574
|
+
if not ok:
|
|
2575
|
+
print(red(" could not create the schedule."))
|
|
2576
|
+
return 1
|
|
2577
|
+
if a.dry_run:
|
|
2578
|
+
print()
|
|
2579
|
+
return 0
|
|
2580
|
+
cfg_set("schedule", enabled=True, freq=freq, day=a.day, at=a.at)
|
|
2581
|
+
print(f"\n {green('✔')} azup will update your tools {freq}"
|
|
2582
|
+
+ (f" on {a.day.capitalize()}s" if freq == "weekly" else "") + f" at {a.at}.")
|
|
2583
|
+
print(dim(" Unattended runs skip anything that needs a password (e.g. the macOS PowerShell cask) and write a log to "
|
|
2584
|
+
+ _p("logs") + ".\n Turn it off with: azup schedule disable\n"))
|
|
2585
|
+
return 0
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
def scheduled_update(a):
|
|
2589
|
+
"""Unattended update: no prompts, no animation, skips anything that needs a password, logs to ~/.azup/logs."""
|
|
2590
|
+
global SCHEDULED
|
|
2591
|
+
SCHEDULED = True
|
|
2592
|
+
os.makedirs(_p("logs"), exist_ok=True)
|
|
2593
|
+
logp = _p("logs", time.strftime("update-%Y%m%d-%H%M%S.log"))
|
|
2594
|
+
lf = open(logp, "w", encoding="utf-8")
|
|
2595
|
+
sys.stdout = Tee(sys.__stdout__, lf)
|
|
2596
|
+
print(f"azup {VERSION} scheduled update · {time.ctime()}")
|
|
2597
|
+
st = detect(True)
|
|
2598
|
+
due = [t for t in TOOLS if st[t.key].outdated and st[t.key].managed]
|
|
2599
|
+
skipped = [t for t in due if t.kind == "pkg" and spec(t)[0] == "cask"] # cask installers ask for a password
|
|
2600
|
+
todo = [t for t in due if t not in skipped]
|
|
2601
|
+
for t in skipped:
|
|
2602
|
+
print(f"skipping {t.name}: needs a password – run `azup update {t.key}` yourself")
|
|
2603
|
+
ok = execute([(t, "update") for t in todo], False, True) if todo else True
|
|
2604
|
+
res = list(LAST_RESULTS)
|
|
2605
|
+
jsave("last-run.json", {"at": time.time(), "log": logp, "seen": False,
|
|
2606
|
+
"updated": [r[0].name for r in res if r[2]],
|
|
2607
|
+
"failed": [r[0].name for r in res if not r[2]],
|
|
2608
|
+
"skipped": [t.name for t in skipped]})
|
|
2609
|
+
lf.close()
|
|
2610
|
+
sys.stdout = sys.__stdout__
|
|
2611
|
+
logs = sorted(glob.glob(_p("logs", "update-*.log")))
|
|
2612
|
+
for old in logs[:-10]: # keep the last 10 logs
|
|
2613
|
+
try:
|
|
2614
|
+
os.remove(old)
|
|
2615
|
+
except OSError:
|
|
2616
|
+
pass
|
|
2617
|
+
return 0 if ok else 1
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
|
|
2621
|
+
def clippy_says(st):
|
|
2622
|
+
"""Context-aware suggestion (message, index of the menu row to preselect)."""
|
|
2623
|
+
n_out = sum(s.outdated for s in st.values())
|
|
2624
|
+
miss = [t for t in TOOLS if t.core and not st[t.key].installed]
|
|
2625
|
+
if n_out:
|
|
2626
|
+
s_ = "s" if n_out != 1 else ""
|
|
2627
|
+
return (f"It looks like {n_out} tool{s_} {'are' if n_out != 1 else 'is'} outdated. "
|
|
2628
|
+
f"Would you like me to update {'them' if n_out != 1 else 'it'}?"), 1
|
|
2629
|
+
if miss:
|
|
2630
|
+
return (f"Your core toolbox is missing {len(miss)} piece{'s' if len(miss) != 1 else ''}. "
|
|
2631
|
+
f"Want me to install {'them' if len(miss) != 1 else 'it'}?"), 2
|
|
2632
|
+
return "Everything is up to date. Go deploy something!", 0
|
|
2633
|
+
|
|
2634
|
+
|
|
2635
|
+
def cmd_joke(a):
|
|
2636
|
+
print()
|
|
2637
|
+
say(random.choice(JOKES), "think")
|
|
2638
|
+
print()
|
|
2639
|
+
return 0
|
|
2640
|
+
|
|
2641
|
+
|
|
2642
|
+
def notifications_menu():
|
|
2643
|
+
while True:
|
|
2644
|
+
cfg = cfg_get()
|
|
2645
|
+
nudge_on, sc = cfg.get("nudge", {}).get("enabled"), cfg.get("schedule", {})
|
|
2646
|
+
sc_on = sc.get("enabled") and sched_active()
|
|
2647
|
+
rows = [("Terminal nudge", "ON – select to turn off" if nudge_on else "OFF – a one-line reminder when you open a terminal", False),
|
|
2648
|
+
("Scheduled auto-update", (f"ON – {sc.get('freq')} {sc.get('at')} – select to turn off" if sc_on
|
|
2649
|
+
else "OFF – update automatically, weekly (Mon 09:00)"), False),
|
|
2650
|
+
("Back", "", False)]
|
|
2651
|
+
c = pick("Notifications & auto-update", rows)
|
|
2652
|
+
if c is None or c == 2:
|
|
2653
|
+
return
|
|
2654
|
+
ns = argparse.Namespace(shell=None, yes=False, action="", daily=False, day="mon", at="09:00", dry_run=False)
|
|
2655
|
+
if c == 0:
|
|
2656
|
+
(nudge_disable if nudge_on else nudge_enable)(ns)
|
|
2657
|
+
else:
|
|
2658
|
+
if sc_on:
|
|
2659
|
+
ns.action = "disable"
|
|
2660
|
+
else:
|
|
2661
|
+
ns.action = "enable"
|
|
2662
|
+
if not confirm("Update your tools automatically every Monday at 09:00?"):
|
|
2663
|
+
continue
|
|
2664
|
+
cmd_schedule(ns)
|
|
2665
|
+
pause()
|
|
2666
|
+
|
|
2667
|
+
|
|
2668
|
+
def interactive():
|
|
2669
|
+
st = load(True)
|
|
2670
|
+
first = True
|
|
2671
|
+
while True:
|
|
2672
|
+
sys.stdout.write("\x1b[2J\x1b[H")
|
|
2673
|
+
banner(animate=first)
|
|
2674
|
+
first = False
|
|
2675
|
+
print_table(st)
|
|
2676
|
+
n_out = sum(s.outdated for s in st.values())
|
|
2677
|
+
msg, start = clippy_says(st)
|
|
2678
|
+
say(msg)
|
|
2679
|
+
print()
|
|
2680
|
+
menu = [("select", "Select components…", "tick what to install or update"),
|
|
2681
|
+
("update", "Update everything", f"{n_out} outdated"),
|
|
2682
|
+
("whatsnew", "What's new?", "release notes for what's outdated"),
|
|
2683
|
+
("core", "Install core profile", "az, azd, bicep, PowerShell, Az, Graph, resource-graph"),
|
|
2684
|
+
("doctor", "Doctor", "sign-in & health"),
|
|
2685
|
+
("whoami", "Who am I?", "account + subscription in every tool"),
|
|
2686
|
+
("ctx", "Switch subscription…", "az + azd + Az PowerShell together"),
|
|
2687
|
+
("login", "Sign in…", "az / azd / PowerShell / Graph"),
|
|
2688
|
+
("notify", "Notifications & auto-update…", "terminal nudge, scheduled updates"),
|
|
2689
|
+
("selfupdate", "Update azup", "get the latest version of this tool"),
|
|
2690
|
+
("refresh", "Refresh", "re-scan"),
|
|
2691
|
+
("quit", "Quit", "")]
|
|
2692
|
+
ids = [m[0] for m in menu]
|
|
2693
|
+
c = pick("What now?", [(m[1], m[2], False) for m in menu], start=ids.index(["select", "update", "core"][start]))
|
|
2694
|
+
act = ids[c] if c is not None else "quit"
|
|
2695
|
+
if act == "quit":
|
|
2696
|
+
print()
|
|
2697
|
+
say("Bye! May all your deployments be green.")
|
|
2698
|
+
print()
|
|
2699
|
+
return 0
|
|
2700
|
+
if act == "select":
|
|
2701
|
+
select_flow(st)
|
|
2702
|
+
elif act == "update":
|
|
2703
|
+
acts = [(t, "update") for t in TOOLS if st[t.key].outdated and st[t.key].managed]
|
|
2704
|
+
if acts and proceed(acts, st):
|
|
2705
|
+
execute(acts)
|
|
2706
|
+
elif not acts:
|
|
2707
|
+
print(f" {green('✔')} nothing to update.\n")
|
|
2708
|
+
elif act == "whatsnew":
|
|
2709
|
+
cmd_changes(argparse.Namespace(names=[]))
|
|
2710
|
+
elif act == "core":
|
|
2711
|
+
execute([(t, "install") for t in TOOLS if t.core and not st[t.key].installed])
|
|
2712
|
+
elif act == "doctor":
|
|
2713
|
+
cmd_doctor(None)
|
|
2714
|
+
elif act == "whoami":
|
|
2715
|
+
cmd_whoami(None)
|
|
2716
|
+
elif act == "ctx":
|
|
2717
|
+
cmd_ctx(argparse.Namespace(query=None, list=False))
|
|
2718
|
+
elif act == "login":
|
|
2719
|
+
s = pick("Sign in", [("Azure CLI", "az login", False), ("Azure Developer CLI", "azd auth login", False),
|
|
2720
|
+
("Az PowerShell", "Connect-AzAccount", False), ("Microsoft Graph", "Connect-MgGraph", False)])
|
|
2721
|
+
if s is not None:
|
|
2722
|
+
cmd_login(argparse.Namespace(target=["az", "azd", "ps", "graph"][s]))
|
|
2723
|
+
elif act == "notify":
|
|
2724
|
+
notifications_menu()
|
|
2725
|
+
st = load(True)
|
|
2726
|
+
continue
|
|
2727
|
+
elif act == "selfupdate":
|
|
2728
|
+
cmd_self_update(None)
|
|
2729
|
+
if act != "refresh":
|
|
2730
|
+
pause()
|
|
2731
|
+
st = load(True)
|
|
2732
|
+
|
|
2733
|
+
|
|
2734
|
+
def main():
|
|
2735
|
+
p = argparse.ArgumentParser(prog="azup", description="Install & update your Azure toolbox.")
|
|
2736
|
+
p.add_argument("-V", "--version", action="version", version=VERSION)
|
|
2737
|
+
p.add_argument("--no-anim", action="store_true", help="disable animations (or set AZUP_NO_ANIM=1)")
|
|
2738
|
+
sub = p.add_subparsers(dest="cmd")
|
|
2739
|
+
|
|
2740
|
+
def flags(sp):
|
|
2741
|
+
sp.add_argument("-n", "--dry-run", action="store_true", help="show commands, change nothing")
|
|
2742
|
+
sp.add_argument("-y", "--yes", action="store_true", help="don't ask for confirmation")
|
|
2743
|
+
sp.add_argument("-j", "--jobs", type=int, metavar="N", default=None,
|
|
2744
|
+
help="run up to N things in parallel (default 4; -j 1 = one at a time; env AZUP_JOBS)")
|
|
2745
|
+
|
|
2746
|
+
s = sub.add_parser("status", aliases=["ls"], help="show installed / outdated")
|
|
2747
|
+
s.add_argument("--offline", action="store_true", help="skip latest-version lookup")
|
|
2748
|
+
s.set_defaults(fn=cmd_status)
|
|
2749
|
+
sl = sub.add_parser("select", aliases=["menu"], help="checklist to install/update components")
|
|
2750
|
+
flags(sl)
|
|
2751
|
+
sl.set_defaults(fn=cmd_select)
|
|
2752
|
+
i = sub.add_parser("install", aliases=["i"], help="install tools (no args = checklist)")
|
|
2753
|
+
i.add_argument("names", nargs="*")
|
|
2754
|
+
i.add_argument("--profile", choices=["core", "full", "ai"],
|
|
2755
|
+
help="core | ai (core + Foundry / AI tooling) | full")
|
|
2756
|
+
i.add_argument("--all", action="store_true")
|
|
2757
|
+
flags(i)
|
|
2758
|
+
i.set_defaults(fn=cmd_install)
|
|
2759
|
+
u = sub.add_parser("update", aliases=["up"], help="update tools (default: all outdated)")
|
|
2760
|
+
u.add_argument("names", nargs="*")
|
|
2761
|
+
u.add_argument("--changes", action="store_true", help="show release notes first, then ask")
|
|
2762
|
+
u.add_argument("--scheduled", action="store_true", help=argparse.SUPPRESS)
|
|
2763
|
+
flags(u)
|
|
2764
|
+
u.set_defaults(fn=cmd_update)
|
|
2765
|
+
nd = sub.add_parser("nudge", help="one-line 'tools are outdated' reminder when you open a terminal")
|
|
2766
|
+
nd.add_argument("action", nargs="?", default="show",
|
|
2767
|
+
choices=["show", "enable", "disable", "status", "snooze", "refresh"])
|
|
2768
|
+
nd.add_argument("arg", nargs="?", help="snooze duration, e.g. 12h, 7d, 2w")
|
|
2769
|
+
nd.add_argument("--shell", choices=["zsh", "bash", "pwsh"], help="which shell profile to edit")
|
|
2770
|
+
nd.add_argument("-y", "--yes", action="store_true", help="don't ask for confirmation")
|
|
2771
|
+
nd.set_defaults(fn=cmd_nudge)
|
|
2772
|
+
sc = sub.add_parser("schedule", help="update automatically on a schedule")
|
|
2773
|
+
sc.add_argument("action", choices=["enable", "disable", "status", "run"])
|
|
2774
|
+
sc.add_argument("--daily", action="store_true", help="every day (default: weekly)")
|
|
2775
|
+
sc.add_argument("--day", choices=DAYS, default="mon", help="weekday for weekly runs (default: mon)")
|
|
2776
|
+
sc.add_argument("--at", default="09:00", help="time of day, 24h HH:MM (default 09:00)")
|
|
2777
|
+
sc.add_argument("-n", "--dry-run", action="store_true", help="show what would be created, change nothing")
|
|
2778
|
+
sc.set_defaults(fn=cmd_schedule)
|
|
2779
|
+
ch = sub.add_parser("changes", aliases=["whatsnew"], help="release notes for what's outdated (or named tools)")
|
|
2780
|
+
ch.add_argument("names", nargs="*")
|
|
2781
|
+
ch.set_defaults(fn=cmd_changes)
|
|
2782
|
+
sub.add_parser("doctor", help="health & sign-in checks").set_defaults(fn=cmd_doctor)
|
|
2783
|
+
sub.add_parser("whoami", aliases=["who"], help="who am I signed in as, in every tool?").set_defaults(fn=cmd_whoami)
|
|
2784
|
+
cx = sub.add_parser("ctx", help="switch subscription in az, azd and Az PowerShell at once")
|
|
2785
|
+
cx.add_argument("query", nargs="?", help="subscription id or part of its name (omit for the picker)")
|
|
2786
|
+
cx.add_argument("--list", action="store_true", help="just list subscriptions")
|
|
2787
|
+
cx.add_argument("--all", action="store_true", help="include disabled subscriptions")
|
|
2788
|
+
cx.set_defaults(fn=cmd_ctx)
|
|
2789
|
+
l = sub.add_parser("login", help="sign in")
|
|
2790
|
+
l.add_argument("target", nargs="?", default="az", choices=["az", "azd", "ps", "graph", "all"])
|
|
2791
|
+
l.add_argument("--tenant", help="tenant id or domain to sign in to")
|
|
2792
|
+
l.set_defaults(fn=cmd_login)
|
|
2793
|
+
sub.add_parser("self-update", help="update azup itself").set_defaults(fn=cmd_self_update)
|
|
2794
|
+
sub.add_parser("joke", help="cheer me up").set_defaults(fn=cmd_joke)
|
|
2795
|
+
a = p.parse_args()
|
|
2796
|
+
try:
|
|
2797
|
+
if not a.cmd:
|
|
2798
|
+
return interactive() if interactive_ok() else (p.print_help() or 0)
|
|
2799
|
+
return a.fn(a)
|
|
2800
|
+
except KeyboardInterrupt:
|
|
2801
|
+
print("\n cancelled.")
|
|
2802
|
+
return 130
|
|
2803
|
+
|
|
2804
|
+
|
|
2805
|
+
if __name__ == "__main__":
|
|
2806
|
+
sys.exit(main())
|