@dahch/heimdall 1.0.1 โ†’ 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -6
  2. package/dist/cli.js +506 -165
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  2. **Aggregated Review & Confirmation**: Shows an interactive tabular overview comparing current and latest package versions across all managers, prompting for confirmation before modifying the system.
21
21
  - ๐Ÿงช **True Simulated Dry-Run (`--dry-run`)**: Traverses the full engine lifecycle, formats outdated packages, and executes mock update routines step-by-step with real-time feedback without touching system files.
22
22
  - โšก **High-Speed Registry Queries**: Yarn and pipx bypass heavy CLI subprocess spawning by querying the npm Registry and PyPI JSON APIs directly over HTTP with strict 3-second abort timeouts.
23
- - ๐Ÿ” **Non-Interactive Sudo Pre-Checks**: Privileged managers (MacPorts, Linux APT) verify cached credentials using `sudo -n true` beforehand, preventing terminal lockups and password prompt hangs.
23
+ - ๐Ÿ” **Interactive Sudo Acquisition**: When a selected manager requires elevation (`requiresSudo`), `hmd` verifies the cached ticket with `sudo -n true` and, if missing, prompts for the password exactly once through `sudo -v` on the controlling terminal before any spinner takes over the TTY. A background keep-alive refreshes the ticket during long upgrades, and every privileged step re-verifies non-interactive credentials so subprocesses can never hang on a hidden password prompt. Running `sudo hmd` is neither required nor supported (the CLI lives under the user's `PATH`, which root's `secure_path` does not include).
24
24
  - ๐ŸŽจ **Modern Minimalist UX**: Terminal interface powered by `@clack/prompts`, micro-border tabular layouts with directional version transitions (`current โ†’ latest`), persistent real-time step progression with elapsed durations, and actionable error extraction.
25
25
  - ๐Ÿท **Dynamic Version Resolution**: Reads package name and version dynamically from `package.json` at runtime via `import.meta.url`, ensuring banners and `--version` flags remain in sync with repository releases.
26
26
  - โš™ **Flexible Targeting**: Run interactively, execute non-interactively with `--yes`, or filter target managers using `--only` and `--exclude`.
@@ -29,7 +29,7 @@
29
29
 
30
30
  ## ๐Ÿ“ฆ Supported Package Managers
31
31
 
32
- Heimdall (`hmd`) supports 13 package managers across system, runtime, language, and application store categories:
32
+ Heimdall (`hmd`) supports 14 package managers across system, runtime, language, and application store categories:
33
33
 
34
34
  | Manager | Icon | Category | Detection Strategy | Update Routine & Resilience Rules |
35
35
  | :--- | :---: | :---: | :--- | :--- |
@@ -44,8 +44,9 @@ Heimdall (`hmd`) supports 13 package managers across system, runtime, language,
44
44
  | **[Ruby Gem](https://rubygems.org)** | ๐Ÿ’Ž | `language` | `gem outdated` (skips deprecated Apple System Ruby `/usr/bin/gem`) | 1. `gem update --user-install`<br>2. `gem cleanup` |
45
45
  | **[MacPorts](https://www.macports.org)** | โš“ | `system` | `port outdated` + non-interactive `sudo -n true` verification | 1. `sudo port selfupdate`<br>2. `sudo port upgrade outdated` |
46
46
  | **[Mac App Store (mas)](https://github.com/mas-cli/mas)** | ๐ŸŽ | `appstore` | `mas outdated` | `mas upgrade` |
47
- | **[APT (Debian/Ubuntu)](https://wiki.debian.org/Apt)** | ๐Ÿง | `system` | `apt list --upgradable` + non-interactive `sudo -n true` check | 1. `sudo apt-get update -y`<br>2. `sudo apt-get upgrade -y`<br>3. `sudo apt-get autoremove -y` |
48
- | **[Flatpak](https://flatpak.org)** | ๐Ÿ“ฆ | `system` | `flatpak remote-ls --updates` | `flatpak update -y --noninteractive` |
47
+ | **[APT (Debian/Ubuntu)](https://wiki.debian.org/Apt)** | ๐Ÿง | `system` | `apt list --upgradable` + non-interactive `sudo -n true` check | 1. `sudo apt-get update -y`<br>2. `sudo apt-get upgrade -y --with-new-pkgs` (avoids leaving dependency-gaining upgrades `kept back`)<br>3. `sudo apt-get autoremove -y` (prompted interactively with confirmation prompt, auto-approved with `--yes` or `--dry-run`, skipped when declined or after a failed upgrade) |
48
+ | **[DNF (Fedora/RHEL)](https://docs.fedoraproject.org/en-US/quick-docs/dnf/)** | ๐Ÿ“ฆ | `system` | `dnf check-update -y` (exit code 100 signals updates) + `sudo -n true` check | 1. `sudo dnf upgrade -y --refresh`<br>2. `sudo dnf autoremove -y` (prompted interactively with confirmation prompt, auto-approved with `--yes` or `--dry-run`) |
49
+ | **[Flatpak](https://flatpak.org)** | ๐Ÿ“ฆ | `system` | `flatpak remote-ls --user --updates` + `flatpak remote-ls --system --updates` (both scopes, deduplicated) | 1. `flatpak update --user -y --noninteractive`<br>2. `sudo flatpak update --system -y --noninteractive` (marked `failed` with sudo guidance when system apps exist but privileges are unavailable, `skipped` when no system-scope apps are installed) |
49
50
 
50
51
  ---
51
52
 
@@ -156,7 +157,9 @@ Test coverage includes:
156
157
  - Concurrency pool bounds and error isolation in [`UpdaterEngine`](src/core/engine.ts).
157
158
  - `NpmManager` targeted batch installation, fallback trigger handling, and `--ignore-scripts` resilience.
158
159
  - `HomebrewManager` selective formula vs. cask execution branches.
159
- - Pre-check behaviors (skipping `/usr/bin/gem`, sudo privilege checks for MacPorts and APT).
160
+ - Pre-check behaviors (skipping `/usr/bin/gem`, sudo privilege checks for MacPorts, APT and DNF, interactive sudo acquisition with ticket keep-alive).
161
+ - `FlatpakManager` dual-scope querying (user + system installations) and system-step sudo gating.
162
+ - `AptManager` upgradable parsing (epochs, architecture-qualified names, header/warning lines) and its consent-gated autoremove branch.
160
163
  - HTTP registry lookup error handling for Yarn and pipx.
161
164
  - Formatting renderers (micro-border table, directional version diffs, execution summary metrics).
162
165
 
@@ -178,7 +181,7 @@ Heimdall employs an automated GitHub Actions CI/CD workflow ([`.github/workflows
178
181
 
179
182
  - [SPEC.md](SPEC.md): Technical specification, CLI contract, data models, and exit codes.
180
183
  - [DESIGN.md](DESIGN.md): System architecture, Two-Phase workflow, Engine concurrency pool, and Mermaid diagrams.
181
- - [ADR.md](ADR.md): Architectural Decision Records (ADR-001 through ADR-009) documenting key engineering decisions.
184
+ - [ADR.md](ADR.md): Architectural Decision Records (ADR-001 through ADR-010) documenting key engineering decisions.
182
185
  - [AGENTS.md](AGENTS.md): Developer and AI agent reference manual for maintaining and extending the codebase.
183
186
 
184
187
  ---
package/dist/cli.js CHANGED
@@ -18,6 +18,26 @@ async function commandExists(command) {
18
18
  return false;
19
19
  }
20
20
  }
21
+ var SUDO_REQUIRED_HINT = `Run "sudo -v" in this terminal and retry ("sudo hmd" will not work: hmd is not in root's PATH).`;
22
+ async function ensureSudo() {
23
+ if (process.getuid?.() === 0) return true;
24
+ const cachedTicket = await safeExec("sudo", ["-n", "true"], { timeoutMs: 5e3 });
25
+ if (cachedTicket.success) return true;
26
+ if (!process.stdin.isTTY || !await commandExists("sudo")) return false;
27
+ try {
28
+ const result = await execa("sudo", ["-v"], { stdio: "inherit", reject: false });
29
+ return result.exitCode === 0;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ function startSudoKeepAlive(intervalMs = 6e4) {
35
+ const timer = setInterval(() => {
36
+ void safeExec("sudo", ["-n", "-v"], { timeoutMs: 1e4 });
37
+ }, intervalMs);
38
+ timer.unref();
39
+ return () => clearInterval(timer);
40
+ }
21
41
  async function safeExec(file, args = [], options = {}) {
22
42
  const timeout = options.timeoutMs ?? 3e4;
23
43
  try {
@@ -140,6 +160,14 @@ var BasePackageManager = class {
140
160
  async isAvailable() {
141
161
  return commandExists(this.binary);
142
162
  }
163
+ /**
164
+ * Verifies that sudo can run without a password prompt (cached ticket or NOPASSWD rule),
165
+ * so privileged steps fail fast instead of hanging inside a stdin-less subprocess.
166
+ */
167
+ async hasPasswordlessSudo() {
168
+ const result = await safeExec("sudo", ["-n", "true"], { timeoutMs: 3e3 });
169
+ return result.success;
170
+ }
143
171
  async executeStep(stepName, file, args, options, extraExecOptions) {
144
172
  const startTime = Date.now();
145
173
  options.onStepStart?.(stepName);
@@ -1311,6 +1339,7 @@ var MacPortsManager = class extends BasePackageManager {
1311
1339
  name = "MacPorts";
1312
1340
  icon = "\u2693";
1313
1341
  category = "system";
1342
+ requiresSudo = true;
1314
1343
  binary = "port";
1315
1344
  async checkUpdates(options) {
1316
1345
  const startTime = Date.now();
@@ -1376,8 +1405,8 @@ var MacPortsManager = class extends BasePackageManager {
1376
1405
  const startTime = Date.now();
1377
1406
  const steps = [];
1378
1407
  if (!options.dryRun && process.getuid?.() !== 0) {
1379
- const sudoCheck = await safeExec("sudo", ["-n", "true"], { timeoutMs: 3e3 });
1380
- if (!sudoCheck.success) {
1408
+ if (!await this.hasPasswordlessSudo()) {
1409
+ const error = `MacPorts requires sudo privileges. ${SUDO_REQUIRED_HINT}`;
1381
1410
  return {
1382
1411
  managerId: this.id,
1383
1412
  managerName: this.name,
@@ -1390,10 +1419,10 @@ var MacPortsManager = class extends BasePackageManager {
1390
1419
  name: "sudo privileges",
1391
1420
  command: "sudo -n true",
1392
1421
  status: "failed",
1393
- error: 'MacPorts requires sudo privileges. Run "sudo -v" in your terminal before hmd.'
1422
+ error
1394
1423
  }
1395
1424
  ],
1396
- error: 'MacPorts requires sudo privileges. Run "sudo -v" before running hmd.'
1425
+ error
1397
1426
  };
1398
1427
  }
1399
1428
  }
@@ -1513,12 +1542,108 @@ var MacAppStoreManager = class extends BasePackageManager {
1513
1542
  }
1514
1543
  };
1515
1544
 
1545
+ // src/utils/formatting.ts
1546
+ import pc from "picocolors";
1547
+ import Table from "cli-table3";
1548
+ function formatDuration(ms) {
1549
+ if (ms < 1e3) return `${ms}ms`;
1550
+ const seconds = (ms / 1e3).toFixed(1);
1551
+ return `${seconds}s`;
1552
+ }
1553
+ function renderUpdatesTable(items) {
1554
+ if (items.length === 0) {
1555
+ return pc.dim(" No updates pending.");
1556
+ }
1557
+ const table = new Table({
1558
+ head: [
1559
+ pc.dim("Manager"),
1560
+ pc.dim("Package"),
1561
+ pc.dim("Type"),
1562
+ pc.dim("Version Transition")
1563
+ ],
1564
+ style: {
1565
+ head: [],
1566
+ border: ["dim"]
1567
+ },
1568
+ chars: {
1569
+ top: "\u2500",
1570
+ "top-mid": "\u2500",
1571
+ "top-left": "\u250C",
1572
+ "top-right": "\u2510",
1573
+ bottom: "\u2500",
1574
+ "bottom-mid": "\u2500",
1575
+ "bottom-left": "\u2514",
1576
+ "bottom-right": "\u2518",
1577
+ left: "\u2502",
1578
+ "left-mid": "\u251C",
1579
+ mid: "\u2500",
1580
+ "mid-mid": "\u253C",
1581
+ right: "\u2502",
1582
+ "right-mid": "\u2524",
1583
+ middle: "\u2502"
1584
+ }
1585
+ });
1586
+ for (const item of items) {
1587
+ const typeLabel = item.type ? pc.dim(`[${item.type}]`) : pc.dim("-");
1588
+ const currentVer = pc.yellow(item.currentVersion || "?");
1589
+ const arrow = pc.dim(" \u2192 ");
1590
+ const latestVer = pc.green(pc.bold(item.latestVersion || "?"));
1591
+ table.push([
1592
+ pc.cyan(item.managerName),
1593
+ pc.bold(pc.white(item.name)),
1594
+ typeLabel,
1595
+ `${currentVer}${arrow}${latestVer}`
1596
+ ]);
1597
+ }
1598
+ return table.toString();
1599
+ }
1600
+ function renderExecutionSummary(results) {
1601
+ const total = results.length;
1602
+ const successful = results.filter((r) => r.success).length;
1603
+ const failed = results.filter((r) => !r.success).length;
1604
+ const totalUpdated = results.reduce((acc, r) => acc + r.updatedCount, 0);
1605
+ const lines = [];
1606
+ lines.push("");
1607
+ lines.push(pc.dim("\u2500".repeat(60)));
1608
+ lines.push(` ${pc.bold(pc.cyan("Heimdall"))} ${pc.dim("\u2022 Execution Summary")}`);
1609
+ lines.push(pc.dim("\u2500".repeat(60)));
1610
+ for (const res of results) {
1611
+ const icon = res.icon || "\u{1F4E6}";
1612
+ const status = res.success ? pc.green("\u2714 SUCCESS") : pc.red("\u2716 FAILED");
1613
+ const time = pc.dim(`(${formatDuration(res.durationMs)})`);
1614
+ const count = res.success ? pc.dim(`\u2022 ${res.updatedCount} updated`) : pc.dim(`\u2022 ${res.updatedCount} updated, ${res.steps.filter((s) => s.status === "failed").length} issue(s)`);
1615
+ lines.push(` ${icon} ${pc.bold(res.managerName)} ${status} ${count} ${time}`);
1616
+ if (!res.success && res.error) {
1617
+ lines.push(` ${pc.red("\u21B3 Error:")} ${pc.dim(res.error.trim().split("\n")[0])}`);
1618
+ }
1619
+ if (res.steps.length > 0) {
1620
+ for (const step of res.steps) {
1621
+ if (step.status === "skipped") continue;
1622
+ const stepStatus = step.status === "success" ? pc.green("\u2714") : step.status === "failed" ? pc.red("\u2716") : pc.dim("\u25CB");
1623
+ const stepDuration = step.durationMs ? pc.dim(`[${formatDuration(step.durationMs)}]`) : "";
1624
+ const stepError = step.error && step.status === "failed" ? pc.red(` (${step.error.split("\n")[0]})`) : "";
1625
+ lines.push(` ${stepStatus} ${pc.dim(step.name)} ${stepDuration}${stepError}`);
1626
+ }
1627
+ }
1628
+ }
1629
+ lines.push(pc.dim("\u2500".repeat(60)));
1630
+ const summaryParts = [
1631
+ pc.green(`${successful}/${total} managers succeeded`),
1632
+ failed > 0 ? pc.red(`${failed} failed`) : null,
1633
+ pc.cyan(`${totalUpdated} package(s) updated`)
1634
+ ].filter(Boolean);
1635
+ lines.push(` ${summaryParts.join(" \u2022 ")}`);
1636
+ lines.push(pc.dim("\u2500".repeat(60)));
1637
+ return lines.join("\n");
1638
+ }
1639
+
1516
1640
  // src/managers/linux.ts
1517
1641
  var AptManager = class extends BasePackageManager {
1518
1642
  id = "apt";
1519
1643
  name = "APT (Debian/Ubuntu)";
1520
1644
  icon = "\u{1F427}";
1521
1645
  category = "system";
1646
+ requiresSudo = true;
1522
1647
  binary = "apt-get";
1523
1648
  async checkUpdates(options) {
1524
1649
  const startTime = Date.now();
@@ -1581,8 +1706,8 @@ var AptManager = class extends BasePackageManager {
1581
1706
  const startTime = Date.now();
1582
1707
  const steps = [];
1583
1708
  if (!options.dryRun && process.getuid?.() !== 0) {
1584
- const sudoCheck = await safeExec("sudo", ["-n", "true"], { timeoutMs: 3e3 });
1585
- if (!sudoCheck.success) {
1709
+ if (!await this.hasPasswordlessSudo()) {
1710
+ const error = `APT requires sudo privileges. ${SUDO_REQUIRED_HINT}`;
1586
1711
  return {
1587
1712
  managerId: this.id,
1588
1713
  managerName: this.name,
@@ -1595,10 +1720,10 @@ var AptManager = class extends BasePackageManager {
1595
1720
  name: "sudo privileges",
1596
1721
  command: "sudo -n true",
1597
1722
  status: "failed",
1598
- error: 'APT requires sudo privileges. Run "sudo -v" in your terminal before hmd.'
1723
+ error
1599
1724
  }
1600
1725
  ],
1601
- error: 'APT requires sudo privileges. Run "sudo -v" before running hmd.'
1726
+ error
1602
1727
  };
1603
1728
  }
1604
1729
  }
@@ -1612,18 +1737,54 @@ var AptManager = class extends BasePackageManager {
1612
1737
  const upgradeStep = await this.executeStep(
1613
1738
  "sudo apt-get upgrade",
1614
1739
  "sudo",
1615
- ["apt-get", "upgrade", "-y"],
1740
+ ["apt-get", "upgrade", "-y", "--with-new-pkgs"],
1616
1741
  options
1617
1742
  );
1618
1743
  steps.push(upgradeStep);
1619
- const cleanupStep = await this.executeStep(
1620
- "sudo apt-get autoremove",
1621
- "sudo",
1622
- ["apt-get", "autoremove", "-y"],
1623
- options
1624
- );
1625
- steps.push(cleanupStep);
1626
- const success = steps.every((s) => s.status === "success");
1744
+ if (upgradeStep.status === "success") {
1745
+ if (options.dryRun) {
1746
+ const autoremoveStep = await this.executeStep(
1747
+ "sudo apt-get autoremove",
1748
+ "sudo",
1749
+ ["apt-get", "autoremove", "-y"],
1750
+ options
1751
+ );
1752
+ steps.push(autoremoveStep);
1753
+ } else if (options.confirmAction) {
1754
+ const shouldAutoremove = await options.confirmAction(
1755
+ "Do you want to run sudo apt-get autoremove to clean up orphaned packages?"
1756
+ );
1757
+ if (shouldAutoremove) {
1758
+ const autoremoveStep = await this.executeStep(
1759
+ "sudo apt-get autoremove",
1760
+ "sudo",
1761
+ ["apt-get", "autoremove", "-y"],
1762
+ options
1763
+ );
1764
+ steps.push(autoremoveStep);
1765
+ } else {
1766
+ steps.push({
1767
+ name: "sudo apt-get autoremove",
1768
+ command: "sudo apt-get autoremove -y",
1769
+ status: "skipped"
1770
+ });
1771
+ }
1772
+ } else {
1773
+ steps.push({
1774
+ name: "sudo apt-get autoremove",
1775
+ command: "sudo apt-get autoremove -y",
1776
+ status: "skipped"
1777
+ });
1778
+ }
1779
+ } else {
1780
+ steps.push({
1781
+ name: "sudo apt-get autoremove",
1782
+ command: "sudo apt-get autoremove -y",
1783
+ status: "skipped"
1784
+ });
1785
+ }
1786
+ const executedSteps = steps.filter((s) => s.status !== "skipped");
1787
+ const success = executedSteps.length > 0 && executedSteps.every((s) => s.status === "success");
1627
1788
  return {
1628
1789
  managerId: this.id,
1629
1790
  managerName: this.name,
@@ -1641,6 +1802,7 @@ var FlatpakManager = class extends BasePackageManager {
1641
1802
  name = "Flatpak";
1642
1803
  icon = "\u{1F4E6}";
1643
1804
  category = "system";
1805
+ requiresSudo = true;
1644
1806
  binary = "flatpak";
1645
1807
  async checkUpdates(options) {
1646
1808
  const startTime = Date.now();
@@ -1657,23 +1819,43 @@ var FlatpakManager = class extends BasePackageManager {
1657
1819
  };
1658
1820
  }
1659
1821
  try {
1660
- const execRes = await safeExec("flatpak", ["remote-ls", "--updates"], {
1661
- timeoutMs: options?.timeoutMs ?? 2e4
1662
- });
1663
- const updates = [];
1664
- const lines = execRes.stdout.split("\n");
1665
- for (const line of lines) {
1666
- const trimmed = line.trim();
1667
- if (!trimmed) continue;
1668
- const parts = trimmed.split(/\s+/);
1669
- updates.push({
1822
+ const timeoutMs = options?.timeoutMs ?? 2e4;
1823
+ const [userRes, systemRes] = await Promise.all([
1824
+ safeExec("flatpak", ["remote-ls", "--user", "--updates"], { timeoutMs }),
1825
+ safeExec("flatpak", ["remote-ls", "--system", "--updates"], { timeoutMs })
1826
+ ]);
1827
+ if (!userRes.success && !systemRes.success) {
1828
+ const raw = (systemRes.stderr || userRes.stderr).trim();
1829
+ return {
1670
1830
  managerId: this.id,
1671
1831
  managerName: this.name,
1672
- name: parts[0],
1673
- currentVersion: "installed",
1674
- latestVersion: parts[1] ?? "latest",
1675
- type: "flatpak"
1676
- });
1832
+ icon: this.icon,
1833
+ category: this.category,
1834
+ available: true,
1835
+ updates: [],
1836
+ durationMs: Date.now() - startTime,
1837
+ error: raw ? extractErrorMessage(raw) : void 0
1838
+ };
1839
+ }
1840
+ const updates = [];
1841
+ const seen = /* @__PURE__ */ new Set();
1842
+ for (const execRes of [userRes, systemRes]) {
1843
+ for (const line of execRes.stdout.split("\n")) {
1844
+ const trimmed = line.trim();
1845
+ if (!trimmed) continue;
1846
+ const parts = trimmed.split(/\s+/);
1847
+ const name = parts[0];
1848
+ if (seen.has(name)) continue;
1849
+ seen.add(name);
1850
+ updates.push({
1851
+ managerId: this.id,
1852
+ managerName: this.name,
1853
+ name,
1854
+ currentVersion: "installed",
1855
+ latestVersion: parts[1] ?? "latest",
1856
+ type: "flatpak"
1857
+ });
1858
+ }
1677
1859
  }
1678
1860
  return {
1679
1861
  managerId: this.id,
@@ -1697,17 +1879,243 @@ var FlatpakManager = class extends BasePackageManager {
1697
1879
  };
1698
1880
  }
1699
1881
  }
1882
+ async hasSystemScopeApps() {
1883
+ const result = await safeExec("flatpak", ["list", "--system", "--columns=application"], {
1884
+ timeoutMs: 1e4
1885
+ });
1886
+ return result.success && result.stdout.trim().length > 0;
1887
+ }
1700
1888
  async executeUpdate(items, options) {
1701
1889
  const startTime = Date.now();
1702
1890
  const steps = [];
1703
- const updateStep = await this.executeStep(
1704
- "flatpak update -y",
1891
+ const userStep = await this.executeStep(
1892
+ "flatpak update --user",
1705
1893
  "flatpak",
1706
- ["update", "-y", "--noninteractive"],
1894
+ ["update", "--user", "-y", "--noninteractive"],
1707
1895
  options
1708
1896
  );
1709
- steps.push(updateStep);
1710
- const success = steps.every((s) => s.status === "success");
1897
+ steps.push(userStep);
1898
+ const sudoStepName = "sudo flatpak update --system";
1899
+ const sudoStepCommand = "sudo flatpak update --system -y --noninteractive";
1900
+ if (options.dryRun) {
1901
+ const systemStep = await this.executeStep(
1902
+ sudoStepName,
1903
+ "sudo",
1904
+ ["flatpak", "update", "--system", "-y", "--noninteractive"],
1905
+ options
1906
+ );
1907
+ steps.push(systemStep);
1908
+ } else if (process.getuid?.() === 0 || await this.hasPasswordlessSudo()) {
1909
+ const systemStep = await this.executeStep(
1910
+ sudoStepName,
1911
+ "sudo",
1912
+ ["flatpak", "update", "--system", "-y", "--noninteractive"],
1913
+ options
1914
+ );
1915
+ steps.push(systemStep);
1916
+ } else if (await this.hasSystemScopeApps()) {
1917
+ steps.push({
1918
+ name: sudoStepName,
1919
+ command: sudoStepCommand,
1920
+ status: "failed",
1921
+ error: `System-wide Flatpak updates require sudo privileges. ${SUDO_REQUIRED_HINT}`
1922
+ });
1923
+ } else {
1924
+ steps.push({
1925
+ name: sudoStepName,
1926
+ command: sudoStepCommand,
1927
+ status: "skipped"
1928
+ });
1929
+ }
1930
+ const executedSteps = steps.filter((s) => s.status !== "skipped");
1931
+ const success = executedSteps.length > 0 && executedSteps.every((s) => s.status === "success");
1932
+ return {
1933
+ managerId: this.id,
1934
+ managerName: this.name,
1935
+ icon: this.icon,
1936
+ success,
1937
+ updatedCount: success ? items.length : 0,
1938
+ durationMs: Date.now() - startTime,
1939
+ steps,
1940
+ error: !success ? steps.find((s) => s.status === "failed")?.error : void 0
1941
+ };
1942
+ }
1943
+ };
1944
+ var DnfManager = class extends BasePackageManager {
1945
+ id = "dnf";
1946
+ name = "DNF (Fedora/RHEL)";
1947
+ icon = "\u{1F4E6}";
1948
+ category = "system";
1949
+ requiresSudo = true;
1950
+ binary = "dnf";
1951
+ async checkUpdates(options) {
1952
+ const startTime = Date.now();
1953
+ const isAvail = await this.isAvailable();
1954
+ if (!isAvail) {
1955
+ return {
1956
+ managerId: this.id,
1957
+ managerName: this.name,
1958
+ icon: this.icon,
1959
+ category: this.category,
1960
+ available: false,
1961
+ updates: [],
1962
+ durationMs: 0
1963
+ };
1964
+ }
1965
+ try {
1966
+ const execRes = await safeExec(this.binary, ["check-update", "-y"], {
1967
+ timeoutMs: options?.timeoutMs ?? 25e3
1968
+ });
1969
+ if (execRes.exitCode !== 0 && execRes.exitCode !== 100) {
1970
+ return {
1971
+ managerId: this.id,
1972
+ managerName: this.name,
1973
+ icon: this.icon,
1974
+ category: this.category,
1975
+ available: true,
1976
+ updates: [],
1977
+ durationMs: Date.now() - startTime,
1978
+ error: (execRes.stderr || execRes.stdout).trim() ? extractErrorMessage(execRes.stderr || execRes.stdout) : `dnf check-update failed with code ${execRes.exitCode}`
1979
+ };
1980
+ }
1981
+ if (execRes.exitCode === 0) {
1982
+ return {
1983
+ managerId: this.id,
1984
+ managerName: this.name,
1985
+ icon: this.icon,
1986
+ category: this.category,
1987
+ available: true,
1988
+ updates: [],
1989
+ durationMs: Date.now() - startTime
1990
+ };
1991
+ }
1992
+ const updates = [];
1993
+ const lines = execRes.stdout.split("\n");
1994
+ let pendingPkg = null;
1995
+ for (const rawLine of lines) {
1996
+ const line = rawLine.trim();
1997
+ if (!line) continue;
1998
+ if (line.startsWith("Updating and loading repositories") || line.startsWith("Repositories loaded") || line.startsWith("Last metadata expiration check") || line.startsWith("Obsoleting Packages") || line.startsWith("Loaded plugins") || line.startsWith("Security:")) {
1999
+ continue;
2000
+ }
2001
+ const combinedLine = pendingPkg ? `${pendingPkg} ${line}` : line;
2002
+ const match = combinedLine.match(/^(\S+?)\.([a-zA-Z0-9_]+)\s+(\S+)\s+(\S+)/);
2003
+ if (match) {
2004
+ const [, name, arch, latestVersion, repo] = match;
2005
+ updates.push({
2006
+ managerId: this.id,
2007
+ managerName: this.name,
2008
+ name,
2009
+ currentVersion: "installed",
2010
+ latestVersion,
2011
+ type: "rpm",
2012
+ extra: `${repo} (${arch})`
2013
+ });
2014
+ pendingPkg = null;
2015
+ } else if (!pendingPkg && /^\S+?\.[a-zA-Z0-9_]+$/.test(line)) {
2016
+ pendingPkg = line;
2017
+ } else {
2018
+ pendingPkg = null;
2019
+ }
2020
+ }
2021
+ return {
2022
+ managerId: this.id,
2023
+ managerName: this.name,
2024
+ icon: this.icon,
2025
+ category: this.category,
2026
+ available: true,
2027
+ updates,
2028
+ durationMs: Date.now() - startTime
2029
+ };
2030
+ } catch (err) {
2031
+ return {
2032
+ managerId: this.id,
2033
+ managerName: this.name,
2034
+ icon: this.icon,
2035
+ category: this.category,
2036
+ available: true,
2037
+ updates: [],
2038
+ durationMs: Date.now() - startTime,
2039
+ error: err instanceof Error ? err.message : String(err)
2040
+ };
2041
+ }
2042
+ }
2043
+ async executeUpdate(items, options) {
2044
+ const startTime = Date.now();
2045
+ const steps = [];
2046
+ if (!options.dryRun && process.getuid?.() !== 0) {
2047
+ if (!await this.hasPasswordlessSudo()) {
2048
+ const error = `DNF requires sudo privileges. ${SUDO_REQUIRED_HINT}`;
2049
+ return {
2050
+ managerId: this.id,
2051
+ managerName: this.name,
2052
+ icon: this.icon,
2053
+ success: false,
2054
+ updatedCount: 0,
2055
+ durationMs: Date.now() - startTime,
2056
+ steps: [
2057
+ {
2058
+ name: "sudo privileges",
2059
+ command: "sudo -n true",
2060
+ status: "failed",
2061
+ error
2062
+ }
2063
+ ],
2064
+ error
2065
+ };
2066
+ }
2067
+ }
2068
+ const upgradeStep = await this.executeStep(
2069
+ "sudo dnf upgrade --refresh",
2070
+ "sudo",
2071
+ ["dnf", "upgrade", "-y", "--refresh"],
2072
+ options
2073
+ );
2074
+ steps.push(upgradeStep);
2075
+ if (upgradeStep.status === "success") {
2076
+ if (options.dryRun) {
2077
+ const autoremoveStep = await this.executeStep(
2078
+ "sudo dnf autoremove",
2079
+ "sudo",
2080
+ ["dnf", "autoremove", "-y"],
2081
+ options
2082
+ );
2083
+ steps.push(autoremoveStep);
2084
+ } else if (options.confirmAction) {
2085
+ const shouldAutoremove = await options.confirmAction(
2086
+ "Do you want to run sudo dnf autoremove to clean up orphaned packages?"
2087
+ );
2088
+ if (shouldAutoremove) {
2089
+ const autoremoveStep = await this.executeStep(
2090
+ "sudo dnf autoremove",
2091
+ "sudo",
2092
+ ["dnf", "autoremove", "-y"],
2093
+ options
2094
+ );
2095
+ steps.push(autoremoveStep);
2096
+ } else {
2097
+ steps.push({
2098
+ name: "sudo dnf autoremove",
2099
+ command: "sudo dnf autoremove -y",
2100
+ status: "skipped"
2101
+ });
2102
+ }
2103
+ } else {
2104
+ steps.push({
2105
+ name: "sudo dnf autoremove",
2106
+ command: "sudo dnf autoremove -y",
2107
+ status: "skipped"
2108
+ });
2109
+ }
2110
+ } else {
2111
+ steps.push({
2112
+ name: "sudo dnf autoremove",
2113
+ command: "sudo dnf autoremove -y",
2114
+ status: "skipped"
2115
+ });
2116
+ }
2117
+ const nonSkippedSteps = steps.filter((s) => s.status !== "skipped");
2118
+ const success = nonSkippedSteps.length > 0 && nonSkippedSteps.every((s) => s.status === "success");
1711
2119
  return {
1712
2120
  managerId: this.id,
1713
2121
  managerName: this.name,
@@ -1736,6 +2144,7 @@ function createDefaultManagers() {
1736
2144
  new MacPortsManager(),
1737
2145
  new MacAppStoreManager(),
1738
2146
  new AptManager(),
2147
+ new DnfManager(),
1739
2148
  new FlatpakManager()
1740
2149
  ];
1741
2150
  }
@@ -1872,101 +2281,6 @@ var UpdaterEngine = class {
1872
2281
  }
1873
2282
  };
1874
2283
 
1875
- // src/utils/formatting.ts
1876
- import pc from "picocolors";
1877
- import Table from "cli-table3";
1878
- function formatDuration(ms) {
1879
- if (ms < 1e3) return `${ms}ms`;
1880
- const seconds = (ms / 1e3).toFixed(1);
1881
- return `${seconds}s`;
1882
- }
1883
- function renderUpdatesTable(items) {
1884
- if (items.length === 0) {
1885
- return pc.dim(" No updates pending.");
1886
- }
1887
- const table = new Table({
1888
- head: [
1889
- pc.dim("Manager"),
1890
- pc.dim("Package"),
1891
- pc.dim("Type"),
1892
- pc.dim("Version Transition")
1893
- ],
1894
- style: {
1895
- head: [],
1896
- border: ["dim"]
1897
- },
1898
- chars: {
1899
- top: "\u2500",
1900
- "top-mid": "\u2500",
1901
- "top-left": "\u250C",
1902
- "top-right": "\u2510",
1903
- bottom: "\u2500",
1904
- "bottom-mid": "\u2500",
1905
- "bottom-left": "\u2514",
1906
- "bottom-right": "\u2518",
1907
- left: "\u2502",
1908
- "left-mid": "\u251C",
1909
- mid: "\u2500",
1910
- "mid-mid": "\u253C",
1911
- right: "\u2502",
1912
- "right-mid": "\u2524",
1913
- middle: "\u2502"
1914
- }
1915
- });
1916
- for (const item of items) {
1917
- const typeLabel = item.type ? pc.dim(`[${item.type}]`) : pc.dim("-");
1918
- const currentVer = pc.yellow(item.currentVersion || "?");
1919
- const arrow = pc.dim(" \u2192 ");
1920
- const latestVer = pc.green(pc.bold(item.latestVersion || "?"));
1921
- table.push([
1922
- pc.cyan(item.managerName),
1923
- pc.bold(pc.white(item.name)),
1924
- typeLabel,
1925
- `${currentVer}${arrow}${latestVer}`
1926
- ]);
1927
- }
1928
- return table.toString();
1929
- }
1930
- function renderExecutionSummary(results) {
1931
- const total = results.length;
1932
- const successful = results.filter((r) => r.success).length;
1933
- const failed = results.filter((r) => !r.success).length;
1934
- const totalUpdated = results.reduce((acc, r) => acc + r.updatedCount, 0);
1935
- const lines = [];
1936
- lines.push("");
1937
- lines.push(pc.dim("\u2500".repeat(60)));
1938
- lines.push(` ${pc.bold(pc.cyan("Heimdall"))} ${pc.dim("\u2022 Execution Summary")}`);
1939
- lines.push(pc.dim("\u2500".repeat(60)));
1940
- for (const res of results) {
1941
- const icon = res.icon || "\u{1F4E6}";
1942
- const status = res.success ? pc.green("\u2714 SUCCESS") : pc.red("\u2716 FAILED");
1943
- const time = pc.dim(`(${formatDuration(res.durationMs)})`);
1944
- const count = res.success ? pc.dim(`\u2022 ${res.updatedCount} updated`) : pc.dim(`\u2022 ${res.updatedCount} updated, ${res.steps.filter((s) => s.status === "failed").length} issue(s)`);
1945
- lines.push(` ${icon} ${pc.bold(res.managerName)} ${status} ${count} ${time}`);
1946
- if (!res.success && res.error) {
1947
- lines.push(` ${pc.red("\u21B3 Error:")} ${pc.dim(res.error.trim().split("\n")[0])}`);
1948
- }
1949
- if (res.steps.length > 0) {
1950
- for (const step of res.steps) {
1951
- if (step.status === "skipped") continue;
1952
- const stepStatus = step.status === "success" ? pc.green("\u2714") : step.status === "failed" ? pc.red("\u2716") : pc.dim("\u25CB");
1953
- const stepDuration = step.durationMs ? pc.dim(`[${formatDuration(step.durationMs)}]`) : "";
1954
- const stepError = step.error && step.status === "failed" ? pc.red(` (${step.error.split("\n")[0]})`) : "";
1955
- lines.push(` ${stepStatus} ${pc.dim(step.name)} ${stepDuration}${stepError}`);
1956
- }
1957
- }
1958
- }
1959
- lines.push(pc.dim("\u2500".repeat(60)));
1960
- const summaryParts = [
1961
- pc.green(`${successful}/${total} managers succeeded`),
1962
- failed > 0 ? pc.red(`${failed} failed`) : null,
1963
- pc.cyan(`${totalUpdated} package(s) updated`)
1964
- ].filter(Boolean);
1965
- lines.push(` ${summaryParts.join(" \u2022 ")}`);
1966
- lines.push(pc.dim("\u2500".repeat(60)));
1967
- return lines.join("\n");
1968
- }
1969
-
1970
2284
  // src/utils/version.ts
1971
2285
  import { fileURLToPath } from "url";
1972
2286
  import { dirname, resolve } from "path";
@@ -2154,6 +2468,21 @@ ${pc2.bold("Pending updates")} ${pc2.dim(`(${allUpdates.length} total):`)}`);
2154
2468
  process.exit(0);
2155
2469
  }
2156
2470
  const isSimulated = options.dryRun ?? false;
2471
+ let stopSudoKeepAlive;
2472
+ const selectedIds = new Set(managersToUpdate);
2473
+ const needsSudo = selectedManagers.some((m) => selectedIds.has(m.id) && m.requiresSudo);
2474
+ if (needsSudo && !isSimulated) {
2475
+ p.log.step(pc2.dim("Requesting sudo privileges required by system package managers..."));
2476
+ if (await ensureSudo()) {
2477
+ stopSudoKeepAlive = startSudoKeepAlive();
2478
+ } else {
2479
+ p.log.warn(
2480
+ pc2.yellow(
2481
+ 'Sudo privileges not granted: system package managers will fail. Run "sudo -v" in this terminal and retry.'
2482
+ )
2483
+ );
2484
+ }
2485
+ }
2157
2486
  p.log.step(
2158
2487
  pc2.bold(
2159
2488
  pc2.cyan(
@@ -2164,43 +2493,55 @@ ${isSimulated ? "[DRY RUN] Simulating" : "Starting"} updates for ${managersToUpd
2164
2493
  );
2165
2494
  const updateSpinner = p.spinner();
2166
2495
  let managerIndex = 0;
2167
- const summary = await engine.execute(
2168
- managersToUpdate,
2169
- itemsByManager,
2170
- {
2171
- verbose: options.verbose,
2172
- dryRun: isSimulated,
2173
- onStepStart: (stepName) => {
2174
- updateSpinner.message(pc2.dim(`Running: ${stepName}...`));
2175
- },
2176
- onStepProgress: (stepName, logLine) => {
2177
- if (logLine) {
2178
- const truncated = logLine.length > 55 ? logLine.slice(0, 52) + "..." : logLine;
2179
- updateSpinner.message(pc2.dim(`${stepName} [${truncated}]`));
2496
+ let summary;
2497
+ try {
2498
+ summary = await engine.execute(
2499
+ managersToUpdate,
2500
+ itemsByManager,
2501
+ {
2502
+ verbose: options.verbose,
2503
+ dryRun: isSimulated,
2504
+ confirmAction: async (message) => {
2505
+ if (options.yes) return true;
2506
+ updateSpinner.stop(pc2.dim("Waiting for user confirmation..."));
2507
+ const confirmed = await p.confirm({ message, initialValue: false });
2508
+ updateSpinner.start(pc2.dim("Continuing update..."));
2509
+ return typeof confirmed === "boolean" ? confirmed : false;
2510
+ },
2511
+ onStepStart: (stepName) => {
2512
+ updateSpinner.message(pc2.dim(`Running: ${stepName}...`));
2513
+ },
2514
+ onStepProgress: (stepName, logLine) => {
2515
+ if (logLine) {
2516
+ const truncated = logLine.length > 55 ? logLine.slice(0, 52) + "..." : logLine;
2517
+ updateSpinner.message(pc2.dim(`${stepName} [${truncated}]`));
2518
+ }
2519
+ },
2520
+ onStepEnd: (stepName, success, error, durationMs) => {
2521
+ const mark = success ? pc2.green("\u2714") : pc2.red("\u2716");
2522
+ const durStr = durationMs !== void 0 ? pc2.dim(` [${formatDuration(durationMs)}]`) : "";
2523
+ const detail = error ? pc2.red(` \u21B3 ${error.split("\n")[0]}`) : "";
2524
+ updateSpinner.stop(` ${mark} ${pc2.dim(stepName)}${durStr}${detail}`);
2525
+ updateSpinner.start(pc2.dim("Working..."));
2180
2526
  }
2181
2527
  },
2182
- onStepEnd: (stepName, success, error, durationMs) => {
2183
- const mark = success ? pc2.green("\u2714") : pc2.red("\u2716");
2184
- const durStr = durationMs !== void 0 ? pc2.dim(` [${formatDuration(durationMs)}]`) : "";
2185
- const detail = error ? pc2.red(` \u21B3 ${error.split("\n")[0]}`) : "";
2186
- updateSpinner.stop(` ${mark} ${pc2.dim(stepName)}${durStr}${detail}`);
2187
- updateSpinner.start(pc2.dim("Working..."));
2188
- }
2189
- },
2190
- (mgr) => {
2191
- managerIndex++;
2192
- console.log(pc2.bold(pc2.cyan(`
2528
+ (mgr) => {
2529
+ managerIndex++;
2530
+ console.log(pc2.bold(pc2.cyan(`
2193
2531
  \u25CF [${managerIndex}/${managersToUpdate.length}] ${mgr.icon} ${mgr.name}`)));
2194
- updateSpinner.start(pc2.dim("Starting update routine..."));
2195
- },
2196
- (mgr, res) => {
2197
- if (res.success) {
2198
- updateSpinner.stop(pc2.green(` \u2714 ${mgr.name} completed in ${formatDuration(res.durationMs)}`));
2199
- } else {
2200
- updateSpinner.stop(pc2.red(` \u2716 ${mgr.name} completed with issues in ${formatDuration(res.durationMs)}`));
2532
+ updateSpinner.start(pc2.dim("Starting update routine..."));
2533
+ },
2534
+ (mgr, res) => {
2535
+ if (res.success) {
2536
+ updateSpinner.stop(pc2.green(` \u2714 ${mgr.name} completed in ${formatDuration(res.durationMs)}`));
2537
+ } else {
2538
+ updateSpinner.stop(pc2.red(` \u2716 ${mgr.name} completed with issues in ${formatDuration(res.durationMs)}`));
2539
+ }
2201
2540
  }
2202
- }
2203
- );
2541
+ );
2542
+ } finally {
2543
+ stopSudoKeepAlive?.();
2544
+ }
2204
2545
  console.log(renderExecutionSummary(summary.results));
2205
2546
  if (summary.failedCount === 0) {
2206
2547
  p.outro(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dahch/heimdall",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Heimdall: resilient validator and updater for system and global packages",
5
5
  "type": "module",
6
6
  "publishConfig": {