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())