@dahch/heimdall 1.1.0 → 1.3.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 +55 -7
  2. package/dist/cli.js +1446 -396
  3. package/package.json +14 -3
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,46 +160,57 @@ 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
- const startTime = Date.now();
145
- options.onStepStart?.(stepName);
146
- if (options.dryRun) {
147
- options.onStepProgress?.(stepName, `[DRY-RUN] Would run: ${file} ${args.join(" ")}`);
148
- options.onStepEnd?.(stepName, true);
149
- return {
150
- name: stepName,
151
- command: `${file} ${args.join(" ")}`,
152
- status: "success",
153
- durationMs: 0
154
- };
155
- }
156
- const result = await safeExec(file, args, {
157
- ...extraExecOptions,
158
- timeoutMs: options.timeoutMs ?? 18e4,
159
- // 3 min per step
160
- onData: (data) => {
161
- if (options.onStepProgress) {
162
- const lines = data.split("\n").map((l) => l.trim()).filter(Boolean);
163
- if (lines.length > 0) {
164
- options.onStepProgress(stepName, lines[lines.length - 1]);
165
- }
166
- }
167
- }
168
- });
169
- const durationMs = Date.now() - startTime;
170
- const success = result.success;
171
- const rawOutput = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
172
- const errorMsg = !success ? result.timedOut ? `Step timed out after ${Math.round((options.timeoutMs ?? 18e4) / 1e3)}s` : extractErrorMessage(rawOutput) : void 0;
173
- options.onStepEnd?.(stepName, success, errorMsg, durationMs);
172
+ return runStep(stepName, file, args, options, extraExecOptions);
173
+ }
174
+ };
175
+ async function runStep(stepName, file, args, options, extraExecOptions) {
176
+ const startTime = Date.now();
177
+ options.onStepStart?.(stepName);
178
+ if (options.dryRun) {
179
+ options.onStepProgress?.(stepName, `[DRY-RUN] Would run: ${file} ${args.join(" ")}`);
180
+ options.onStepEnd?.(stepName, true);
174
181
  return {
175
182
  name: stepName,
176
183
  command: `${file} ${args.join(" ")}`,
177
- status: success ? "success" : "failed",
178
- durationMs,
179
- error: errorMsg
184
+ status: "success",
185
+ durationMs: 0
180
186
  };
181
187
  }
182
- };
188
+ const result = await safeExec(file, args, {
189
+ ...extraExecOptions,
190
+ timeoutMs: options.timeoutMs ?? 18e4,
191
+ // 3 min per step
192
+ onData: (data) => {
193
+ if (options.onStepProgress) {
194
+ const lines = data.split("\n").map((l) => l.trim()).filter(Boolean);
195
+ if (lines.length > 0) {
196
+ options.onStepProgress(stepName, lines[lines.length - 1]);
197
+ }
198
+ }
199
+ }
200
+ });
201
+ const durationMs = Date.now() - startTime;
202
+ const success = result.success;
203
+ const rawOutput = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
204
+ const errorMsg = !success ? result.timedOut ? `Step timed out after ${Math.round((options.timeoutMs ?? 18e4) / 1e3)}s` : extractErrorMessage(rawOutput) : void 0;
205
+ options.onStepEnd?.(stepName, success, errorMsg, durationMs);
206
+ return {
207
+ name: stepName,
208
+ command: `${file} ${args.join(" ")}`,
209
+ status: success ? "success" : "failed",
210
+ durationMs,
211
+ error: errorMsg
212
+ };
213
+ }
183
214
 
184
215
  // src/managers/homebrew.ts
185
216
  var HomebrewManager = class extends BasePackageManager {
@@ -393,15 +424,25 @@ var NpmManager = class extends BasePackageManager {
393
424
  });
394
425
  const updates = [];
395
426
  const stdout = execRes.stdout.trim();
427
+ let npmError;
396
428
  if (stdout.startsWith("{")) {
397
429
  const parsed = JSON.parse(stdout);
398
430
  for (const [name, info] of Object.entries(parsed)) {
431
+ if (name === "error" || name === "problems") {
432
+ if (name === "error" && info && typeof info === "object" && "summary" in info) {
433
+ npmError = typeof info.summary === "string" ? info.summary : void 0;
434
+ }
435
+ continue;
436
+ }
437
+ if (!info || typeof info !== "object") continue;
438
+ const entry = info;
439
+ if (!entry.current && !entry.latest && !entry.wanted) continue;
399
440
  updates.push({
400
441
  managerId: this.id,
401
442
  managerName: this.name,
402
443
  name,
403
- currentVersion: info.current ?? "unknown",
404
- latestVersion: info.latest ?? info.wanted ?? "latest",
444
+ currentVersion: entry.current ?? "unknown",
445
+ latestVersion: entry.latest ?? entry.wanted ?? "latest",
405
446
  type: "global-pkg"
406
447
  });
407
448
  }
@@ -413,7 +454,8 @@ var NpmManager = class extends BasePackageManager {
413
454
  category: this.category,
414
455
  available: true,
415
456
  updates,
416
- durationMs: Date.now() - startTime
457
+ durationMs: Date.now() - startTime,
458
+ error: updates.length === 0 ? npmError : void 0
417
459
  };
418
460
  } catch (err) {
419
461
  return {
@@ -1074,15 +1116,30 @@ var PipxManager = class extends BasePackageManager {
1074
1116
  }
1075
1117
  };
1076
1118
 
1077
- // src/managers/cargo.ts
1078
- var CargoManager = class extends BasePackageManager {
1079
- id = "cargo";
1080
- name = "Rust (Cargo & Rustup)";
1081
- icon = "\u{1F980}";
1119
+ // src/managers/uv.ts
1120
+ var UvManager = class extends BasePackageManager {
1121
+ id = "uv";
1122
+ name = "uv (Python tools)";
1123
+ icon = "\u{1F9EA}";
1082
1124
  category = "language";
1083
- binary = "cargo";
1084
- async isAvailable() {
1085
- return await commandExists("cargo") || await commandExists("rustup");
1125
+ binary = "uv";
1126
+ /**
1127
+ * Parses `uv tool list` output, e.g.:
1128
+ * black v24.10.0
1129
+ * - black
1130
+ * - blackd
1131
+ */
1132
+ parseToolList(stdout) {
1133
+ const tools = [];
1134
+ for (const rawLine of stdout.split("\n")) {
1135
+ const line = rawLine.trim();
1136
+ if (!line || line.startsWith("-")) continue;
1137
+ const match = line.match(/^(\S+)\s+v?(\d[0-9A-Za-z.+-]*)(?:\s+\(.*\))?$/);
1138
+ if (match) {
1139
+ tools.push({ name: match[1], version: match[2] });
1140
+ }
1141
+ }
1142
+ return tools;
1086
1143
  }
1087
1144
  async checkUpdates(options) {
1088
1145
  const startTime = Date.now();
@@ -1099,49 +1156,36 @@ var CargoManager = class extends BasePackageManager {
1099
1156
  };
1100
1157
  }
1101
1158
  try {
1159
+ const execRes = await safeExec("uv", ["tool", "list"], {
1160
+ timeoutMs: options?.timeoutMs ?? 15e3
1161
+ });
1102
1162
  const updates = [];
1103
- if (await commandExists("rustup")) {
1104
- const checkRes = await safeExec("rustup", ["check"], {
1105
- timeoutMs: options?.timeoutMs ?? 15e3
1106
- });
1107
- const lines = checkRes.stdout.split("\n");
1108
- for (const line of lines) {
1109
- if (line.includes("Update available")) {
1110
- const parts = line.trim().split(/\s+/);
1111
- const toolchain = parts[0] ?? "toolchain";
1112
- updates.push({
1113
- managerId: this.id,
1114
- managerName: this.name,
1115
- name: `rustup (${toolchain})`,
1116
- currentVersion: "installed",
1117
- latestVersion: "update-available",
1118
- type: "toolchain"
1119
- });
1120
- }
1121
- }
1122
- }
1123
- if (await commandExists("cargo-install-update")) {
1124
- const listRes = await safeExec("cargo", ["install-update", "-l"], {
1125
- timeoutMs: options?.timeoutMs ?? 15e3
1126
- });
1127
- const lines = listRes.stdout.split("\n");
1128
- for (const line of lines) {
1129
- const match = line.match(/^(\S+)\s+v(\S+)\s+v(\S+)/);
1130
- if (match) {
1131
- const [, name, cur, latest] = match;
1132
- if (cur !== latest) {
1163
+ const tools = execRes.success ? this.parseToolList(execRes.stdout) : [];
1164
+ await Promise.all(
1165
+ tools.map(async ({ name, version }) => {
1166
+ try {
1167
+ const response = await fetch(
1168
+ `https://pypi.org/pypi/${encodeURIComponent(name)}/json`,
1169
+ { signal: AbortSignal.timeout(3e3) }
1170
+ );
1171
+ if (!response.ok) return;
1172
+ const data = await response.json();
1173
+ const latest = data.info?.version;
1174
+ if (latest && latest !== version) {
1133
1175
  updates.push({
1134
1176
  managerId: this.id,
1135
1177
  managerName: this.name,
1136
1178
  name,
1137
- currentVersion: cur,
1179
+ currentVersion: version,
1138
1180
  latestVersion: latest,
1139
- type: "crate"
1181
+ type: "uv-tool"
1140
1182
  });
1141
1183
  }
1184
+ } catch {
1142
1185
  }
1143
- }
1144
- }
1186
+ })
1187
+ );
1188
+ updates.sort((a, b) => a.name.localeCompare(b.name));
1145
1189
  return {
1146
1190
  managerId: this.id,
1147
1191
  managerName: this.name,
@@ -1167,26 +1211,29 @@ var CargoManager = class extends BasePackageManager {
1167
1211
  async executeUpdate(items, options) {
1168
1212
  const startTime = Date.now();
1169
1213
  const steps = [];
1170
- if (await commandExists("rustup")) {
1171
- const rustupStep = await this.executeStep(
1172
- "rustup update",
1173
- "rustup",
1174
- ["update"],
1175
- options
1176
- );
1177
- steps.push(rustupStep);
1178
- }
1179
- if (await commandExists("cargo-install-update")) {
1180
- const cargoStep = await this.executeStep(
1181
- "cargo install-update -a",
1182
- "cargo",
1183
- ["install-update", "-a"],
1214
+ if (items.length > 0) {
1215
+ const upgradeStep = await this.executeStep(
1216
+ `uv tool upgrade --all (${items.length} tools)`,
1217
+ "uv",
1218
+ ["tool", "upgrade", "--all"],
1184
1219
  options
1185
1220
  );
1186
- steps.push(cargoStep);
1221
+ steps.push(upgradeStep);
1222
+ const pinned = items.filter((i) => {
1223
+ const major = (v) => v.replace(/^v/, "").split(".")[0];
1224
+ return major(i.currentVersion) !== major(i.latestVersion);
1225
+ });
1226
+ for (const item of pinned) {
1227
+ const step = await this.executeStep(
1228
+ `uv tool install ${item.name}@latest`,
1229
+ "uv",
1230
+ ["tool", "install", `${item.name}@latest`],
1231
+ options
1232
+ );
1233
+ steps.push(step);
1234
+ }
1187
1235
  }
1188
- const hasFailedStep = steps.some((s) => s.status === "failed");
1189
- const success = !hasFailedStep;
1236
+ const success = steps.every((s) => s.status === "success");
1190
1237
  return {
1191
1238
  managerId: this.id,
1192
1239
  managerName: this.name,
@@ -1200,22 +1247,17 @@ var CargoManager = class extends BasePackageManager {
1200
1247
  }
1201
1248
  };
1202
1249
 
1203
- // src/managers/gem.ts
1204
- var RubyGemManager = class extends BasePackageManager {
1205
- id = "gem";
1206
- name = "Ruby Gem";
1207
- icon = "\u{1F48E}";
1250
+ // src/managers/poetry.ts
1251
+ var PoetryManager = class extends BasePackageManager {
1252
+ id = "poetry";
1253
+ name = "Poetry (self-managed)";
1254
+ icon = "\u{1F4DC}";
1208
1255
  category = "language";
1209
- binary = "gem";
1210
- async isAvailable() {
1211
- const hasCmd = await commandExists(this.binary);
1212
- if (!hasCmd) return false;
1213
- const whichRes = await safeExec("which", ["gem"], { timeoutMs: 3e3 });
1214
- const gemPath = whichRes.stdout.trim();
1215
- if (gemPath === "/usr/bin/gem" || gemPath.startsWith("/System/Library")) {
1216
- return false;
1217
- }
1218
- return true;
1256
+ binary = "poetry";
1257
+ /** Parses `Poetry (version 1.8.5)` and older/newer banner variants. */
1258
+ parseInstalledVersion(stdout) {
1259
+ const match = stdout.match(/version\s+(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/i);
1260
+ return match?.[1];
1219
1261
  }
1220
1262
  async checkUpdates(options) {
1221
1263
  const startTime = Date.now();
@@ -1232,24 +1274,31 @@ var RubyGemManager = class extends BasePackageManager {
1232
1274
  };
1233
1275
  }
1234
1276
  try {
1235
- const execRes = await safeExec("gem", ["outdated"], {
1236
- timeoutMs: options?.timeoutMs ?? 2e4
1277
+ const execRes = await safeExec("poetry", ["--version"], {
1278
+ timeoutMs: options?.timeoutMs ?? 15e3
1237
1279
  });
1238
1280
  const updates = [];
1239
- const lines = execRes.stdout.split("\n");
1240
- for (const line of lines) {
1241
- const trimmed = line.trim();
1242
- const match = trimmed.match(/^(\S+)\s*\(([^<]+)<\s*([^)]+)\)/);
1243
- if (match) {
1244
- const [, name, current, latest] = match;
1245
- updates.push({
1246
- managerId: this.id,
1247
- managerName: this.name,
1248
- name: name.trim(),
1249
- currentVersion: current.trim(),
1250
- latestVersion: latest.trim(),
1251
- type: "gem"
1281
+ const current = this.parseInstalledVersion(execRes.stdout);
1282
+ if (current) {
1283
+ try {
1284
+ const response = await fetch("https://pypi.org/pypi/poetry/json", {
1285
+ signal: AbortSignal.timeout(3e3)
1252
1286
  });
1287
+ if (response.ok) {
1288
+ const data = await response.json();
1289
+ const latest = data.info?.version;
1290
+ if (latest && latest !== current) {
1291
+ updates.push({
1292
+ managerId: this.id,
1293
+ managerName: this.name,
1294
+ name: "poetry",
1295
+ currentVersion: current,
1296
+ latestVersion: latest,
1297
+ type: "poetry-self"
1298
+ });
1299
+ }
1300
+ }
1301
+ } catch {
1253
1302
  }
1254
1303
  }
1255
1304
  return {
@@ -1277,20 +1326,15 @@ var RubyGemManager = class extends BasePackageManager {
1277
1326
  async executeUpdate(items, options) {
1278
1327
  const startTime = Date.now();
1279
1328
  const steps = [];
1280
- const updateStep = await this.executeStep(
1281
- "gem update --user-install",
1282
- "gem",
1283
- ["update", "--user-install"],
1284
- options
1285
- );
1286
- steps.push(updateStep);
1287
- const cleanupStep = await this.executeStep(
1288
- "gem cleanup",
1289
- "gem",
1290
- ["cleanup"],
1291
- options
1292
- );
1293
- steps.push(cleanupStep);
1329
+ if (items.length > 0) {
1330
+ const step = await this.executeStep(
1331
+ "poetry self update",
1332
+ "poetry",
1333
+ ["self", "update"],
1334
+ options
1335
+ );
1336
+ steps.push(step);
1337
+ }
1294
1338
  const success = steps.every((s) => s.status === "success");
1295
1339
  return {
1296
1340
  managerId: this.id,
@@ -1305,81 +1349,526 @@ var RubyGemManager = class extends BasePackageManager {
1305
1349
  }
1306
1350
  };
1307
1351
 
1308
- // src/managers/macports.ts
1309
- var MacPortsManager = class extends BasePackageManager {
1310
- id = "macports";
1311
- name = "MacPorts";
1312
- icon = "\u2693";
1313
- category = "system";
1314
- binary = "port";
1315
- async checkUpdates(options) {
1316
- const startTime = Date.now();
1317
- const isAvail = await this.isAvailable();
1318
- if (!isAvail) {
1319
- return {
1320
- managerId: this.id,
1321
- managerName: this.name,
1322
- icon: this.icon,
1323
- category: this.category,
1324
- available: false,
1325
- updates: [],
1326
- durationMs: 0
1327
- };
1328
- }
1329
- try {
1330
- const execRes = await safeExec("port", ["outdated"], {
1331
- timeoutMs: options?.timeoutMs ?? 2e4
1332
- });
1333
- const updates = [];
1334
- const lines = execRes.stdout.split("\n");
1335
- for (const line of lines) {
1336
- const trimmed = line.trim();
1337
- if (!trimmed || trimmed.startsWith("The following") || trimmed.startsWith("--")) {
1338
- continue;
1339
- }
1340
- const match = trimmed.match(/^(\S+)\s+(\S+)\s*<\s*(\S+)/);
1341
- if (match) {
1342
- const [, name, current, latest] = match;
1343
- updates.push({
1344
- managerId: this.id,
1345
- managerName: this.name,
1346
- name,
1347
- currentVersion: current,
1348
- latestVersion: latest,
1349
- type: "port"
1350
- });
1351
- }
1352
- }
1353
- return {
1354
- managerId: this.id,
1355
- managerName: this.name,
1356
- icon: this.icon,
1357
- category: this.category,
1358
- available: true,
1359
- updates,
1360
- durationMs: Date.now() - startTime
1361
- };
1362
- } catch (err) {
1363
- return {
1364
- managerId: this.id,
1365
- managerName: this.name,
1366
- icon: this.icon,
1367
- category: this.category,
1368
- available: true,
1369
- updates: [],
1370
- durationMs: Date.now() - startTime,
1371
- error: err instanceof Error ? err.message : String(err)
1372
- };
1373
- }
1352
+ // src/utils/formatting.ts
1353
+ import pc from "picocolors";
1354
+ import Table from "cli-table3";
1355
+ function formatDuration(ms) {
1356
+ if (ms < 1e3) return `${ms}ms`;
1357
+ const seconds = (ms / 1e3).toFixed(1);
1358
+ return `${seconds}s`;
1359
+ }
1360
+ function renderUpdatesTable(items) {
1361
+ if (items.length === 0) {
1362
+ return pc.dim(" No updates pending.");
1374
1363
  }
1375
- async executeUpdate(items, options) {
1376
- const startTime = Date.now();
1377
- const steps = [];
1378
- if (!options.dryRun && process.getuid?.() !== 0) {
1379
- const sudoCheck = await safeExec("sudo", ["-n", "true"], { timeoutMs: 3e3 });
1380
- if (!sudoCheck.success) {
1381
- return {
1382
- managerId: this.id,
1364
+ const table = new Table({
1365
+ head: [
1366
+ pc.dim("Manager"),
1367
+ pc.dim("Package"),
1368
+ pc.dim("Type"),
1369
+ pc.dim("Version Transition")
1370
+ ],
1371
+ style: {
1372
+ head: [],
1373
+ border: ["dim"]
1374
+ },
1375
+ chars: {
1376
+ top: "\u2500",
1377
+ "top-mid": "\u2500",
1378
+ "top-left": "\u250C",
1379
+ "top-right": "\u2510",
1380
+ bottom: "\u2500",
1381
+ "bottom-mid": "\u2500",
1382
+ "bottom-left": "\u2514",
1383
+ "bottom-right": "\u2518",
1384
+ left: "\u2502",
1385
+ "left-mid": "\u251C",
1386
+ mid: "\u2500",
1387
+ "mid-mid": "\u253C",
1388
+ right: "\u2502",
1389
+ "right-mid": "\u2524",
1390
+ middle: "\u2502"
1391
+ }
1392
+ });
1393
+ for (const item of items) {
1394
+ const typeLabel = item.type ? pc.dim(`[${item.type}]`) : pc.dim("-");
1395
+ const currentVer = pc.yellow(item.currentVersion || "?");
1396
+ const arrow = pc.dim(" \u2192 ");
1397
+ const latestVer = pc.green(pc.bold(item.latestVersion || "?"));
1398
+ table.push([
1399
+ pc.cyan(item.managerName),
1400
+ pc.bold(pc.white(item.name)),
1401
+ typeLabel,
1402
+ `${currentVer}${arrow}${latestVer}`
1403
+ ]);
1404
+ }
1405
+ return table.toString();
1406
+ }
1407
+ function renderExecutionSummary(results) {
1408
+ const total = results.length;
1409
+ const successful = results.filter((r) => r.success).length;
1410
+ const failed = results.filter((r) => !r.success).length;
1411
+ const totalUpdated = results.reduce((acc, r) => acc + r.updatedCount, 0);
1412
+ const lines = [];
1413
+ lines.push("");
1414
+ lines.push(pc.dim("\u2500".repeat(60)));
1415
+ lines.push(` ${pc.bold(pc.cyan("Heimdall"))} ${pc.dim("\u2022 Execution Summary")}`);
1416
+ lines.push(pc.dim("\u2500".repeat(60)));
1417
+ for (const res of results) {
1418
+ const icon = res.icon || "\u{1F4E6}";
1419
+ const status = res.success ? pc.green("\u2714 SUCCESS") : pc.red("\u2716 FAILED");
1420
+ const time = pc.dim(`(${formatDuration(res.durationMs)})`);
1421
+ 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)`);
1422
+ lines.push(` ${icon} ${pc.bold(res.managerName)} ${status} ${count} ${time}`);
1423
+ if (!res.success && res.error) {
1424
+ lines.push(` ${pc.red("\u21B3 Error:")} ${pc.dim(res.error.trim().split("\n")[0])}`);
1425
+ }
1426
+ if (res.steps.length > 0) {
1427
+ for (const step of res.steps) {
1428
+ if (step.status === "skipped") continue;
1429
+ const stepStatus = step.status === "success" ? pc.green("\u2714") : step.status === "failed" ? pc.red("\u2716") : pc.dim("\u25CB");
1430
+ const stepDuration = step.durationMs ? pc.dim(`[${formatDuration(step.durationMs)}]`) : "";
1431
+ const stepError = step.error && step.status === "failed" ? pc.red(` (${step.error.split("\n")[0]})`) : "";
1432
+ lines.push(` ${stepStatus} ${pc.dim(step.name)} ${stepDuration}${stepError}`);
1433
+ }
1434
+ }
1435
+ }
1436
+ lines.push(pc.dim("\u2500".repeat(60)));
1437
+ const summaryParts = [
1438
+ pc.green(`${successful}/${total} managers succeeded`),
1439
+ failed > 0 ? pc.red(`${failed} failed`) : null,
1440
+ pc.cyan(`${totalUpdated} package(s) updated`)
1441
+ ].filter(Boolean);
1442
+ lines.push(` ${summaryParts.join(" \u2022 ")}`);
1443
+ lines.push(pc.dim("\u2500".repeat(60)));
1444
+ return lines.join("\n");
1445
+ }
1446
+
1447
+ // src/managers/conda.ts
1448
+ var CondaManager = class extends BasePackageManager {
1449
+ id = "conda";
1450
+ name = "Conda (Anaconda/Miniforge)";
1451
+ icon = "\u{1F152}";
1452
+ category = "language";
1453
+ binary = "conda";
1454
+ parseDryRun(stdout) {
1455
+ const jsonStart = stdout.indexOf("{");
1456
+ const jsonEnd = stdout.lastIndexOf("}");
1457
+ if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) return [];
1458
+ let parsed;
1459
+ try {
1460
+ parsed = JSON.parse(stdout.slice(jsonStart, jsonEnd + 1));
1461
+ } catch {
1462
+ return [];
1463
+ }
1464
+ const unlinked = /* @__PURE__ */ new Map();
1465
+ for (const entry of parsed.actions?.UNLINK ?? []) {
1466
+ if (entry.name) unlinked.set(entry.name, entry);
1467
+ }
1468
+ const updates = [];
1469
+ for (const entry of parsed.actions?.LINK ?? []) {
1470
+ if (!entry.name || !entry.version) continue;
1471
+ const previous = unlinked.get(entry.name);
1472
+ if (!previous) continue;
1473
+ updates.push({
1474
+ name: entry.name,
1475
+ currentVersion: previous.version,
1476
+ latestVersion: entry.version,
1477
+ channel: entry.channel
1478
+ });
1479
+ }
1480
+ return updates;
1481
+ }
1482
+ async checkUpdates(options) {
1483
+ const startTime = Date.now();
1484
+ const isAvail = await this.isAvailable();
1485
+ if (!isAvail) {
1486
+ return {
1487
+ managerId: this.id,
1488
+ managerName: this.name,
1489
+ icon: this.icon,
1490
+ category: this.category,
1491
+ available: false,
1492
+ updates: [],
1493
+ durationMs: 0
1494
+ };
1495
+ }
1496
+ try {
1497
+ const timeoutMs = Math.max(options?.timeoutMs ?? 6e4, 6e4);
1498
+ const execRes = await safeExec("conda", ["update", "--all", "--dry-run", "--json"], {
1499
+ timeoutMs
1500
+ });
1501
+ const updates = [];
1502
+ if (execRes.success) {
1503
+ for (const action of this.parseDryRun(execRes.stdout)) {
1504
+ updates.push({
1505
+ managerId: this.id,
1506
+ managerName: this.name,
1507
+ name: action.name,
1508
+ currentVersion: action.currentVersion ?? "installed",
1509
+ latestVersion: action.latestVersion,
1510
+ type: "conda-pkg",
1511
+ extra: action.channel
1512
+ });
1513
+ }
1514
+ }
1515
+ const rawError = (execRes.stderr || execRes.stdout).trim();
1516
+ return {
1517
+ managerId: this.id,
1518
+ managerName: this.name,
1519
+ icon: this.icon,
1520
+ category: this.category,
1521
+ available: true,
1522
+ updates,
1523
+ durationMs: Date.now() - startTime,
1524
+ error: !execRes.success && rawError ? extractErrorMessage(rawError) : void 0
1525
+ };
1526
+ } catch (err) {
1527
+ return {
1528
+ managerId: this.id,
1529
+ managerName: this.name,
1530
+ icon: this.icon,
1531
+ category: this.category,
1532
+ available: true,
1533
+ updates: [],
1534
+ durationMs: Date.now() - startTime,
1535
+ error: err instanceof Error ? err.message : String(err)
1536
+ };
1537
+ }
1538
+ }
1539
+ async executeUpdate(items, options) {
1540
+ const startTime = Date.now();
1541
+ const steps = [];
1542
+ if (items.length > 0) {
1543
+ const step = await this.executeStep(
1544
+ `conda update --all (${items.length} pkgs)`,
1545
+ "conda",
1546
+ ["update", "--all", "-y"],
1547
+ options
1548
+ );
1549
+ steps.push(step);
1550
+ }
1551
+ const success = steps.every((s) => s.status === "success");
1552
+ return {
1553
+ managerId: this.id,
1554
+ managerName: this.name,
1555
+ icon: this.icon,
1556
+ success,
1557
+ updatedCount: success ? items.length : 0,
1558
+ durationMs: Date.now() - startTime,
1559
+ steps,
1560
+ error: !success ? steps.find((s) => s.status === "failed")?.error : void 0
1561
+ };
1562
+ }
1563
+ };
1564
+
1565
+ // src/managers/cargo.ts
1566
+ var CargoManager = class extends BasePackageManager {
1567
+ id = "cargo";
1568
+ name = "Rust (Cargo & Rustup)";
1569
+ icon = "\u{1F980}";
1570
+ category = "language";
1571
+ binary = "cargo";
1572
+ async isAvailable() {
1573
+ return await commandExists("cargo") || await commandExists("rustup");
1574
+ }
1575
+ async checkUpdates(options) {
1576
+ const startTime = Date.now();
1577
+ const isAvail = await this.isAvailable();
1578
+ if (!isAvail) {
1579
+ return {
1580
+ managerId: this.id,
1581
+ managerName: this.name,
1582
+ icon: this.icon,
1583
+ category: this.category,
1584
+ available: false,
1585
+ updates: [],
1586
+ durationMs: 0
1587
+ };
1588
+ }
1589
+ try {
1590
+ const updates = [];
1591
+ if (await commandExists("rustup")) {
1592
+ const checkRes = await safeExec("rustup", ["check"], {
1593
+ timeoutMs: options?.timeoutMs ?? 15e3
1594
+ });
1595
+ const lines = checkRes.stdout.split("\n");
1596
+ for (const line of lines) {
1597
+ if (line.includes("Update available")) {
1598
+ const parts = line.trim().split(/\s+/);
1599
+ const toolchain = parts[0] ?? "toolchain";
1600
+ updates.push({
1601
+ managerId: this.id,
1602
+ managerName: this.name,
1603
+ name: `rustup (${toolchain})`,
1604
+ currentVersion: "installed",
1605
+ latestVersion: "update-available",
1606
+ type: "toolchain"
1607
+ });
1608
+ }
1609
+ }
1610
+ }
1611
+ if (await commandExists("cargo-install-update")) {
1612
+ const listRes = await safeExec("cargo", ["install-update", "-l"], {
1613
+ timeoutMs: options?.timeoutMs ?? 15e3
1614
+ });
1615
+ const lines = listRes.stdout.split("\n");
1616
+ for (const line of lines) {
1617
+ const match = line.match(/^(\S+)\s+v(\S+)\s+v(\S+)/);
1618
+ if (match) {
1619
+ const [, name, cur, latest] = match;
1620
+ if (cur !== latest) {
1621
+ updates.push({
1622
+ managerId: this.id,
1623
+ managerName: this.name,
1624
+ name,
1625
+ currentVersion: cur,
1626
+ latestVersion: latest,
1627
+ type: "crate"
1628
+ });
1629
+ }
1630
+ }
1631
+ }
1632
+ }
1633
+ return {
1634
+ managerId: this.id,
1635
+ managerName: this.name,
1636
+ icon: this.icon,
1637
+ category: this.category,
1638
+ available: true,
1639
+ updates,
1640
+ durationMs: Date.now() - startTime
1641
+ };
1642
+ } catch (err) {
1643
+ return {
1644
+ managerId: this.id,
1645
+ managerName: this.name,
1646
+ icon: this.icon,
1647
+ category: this.category,
1648
+ available: true,
1649
+ updates: [],
1650
+ durationMs: Date.now() - startTime,
1651
+ error: err instanceof Error ? err.message : String(err)
1652
+ };
1653
+ }
1654
+ }
1655
+ async executeUpdate(items, options) {
1656
+ const startTime = Date.now();
1657
+ const steps = [];
1658
+ if (await commandExists("rustup")) {
1659
+ const rustupStep = await this.executeStep(
1660
+ "rustup update",
1661
+ "rustup",
1662
+ ["update"],
1663
+ options
1664
+ );
1665
+ steps.push(rustupStep);
1666
+ }
1667
+ if (await commandExists("cargo-install-update")) {
1668
+ const cargoStep = await this.executeStep(
1669
+ "cargo install-update -a",
1670
+ "cargo",
1671
+ ["install-update", "-a"],
1672
+ options
1673
+ );
1674
+ steps.push(cargoStep);
1675
+ }
1676
+ const hasFailedStep = steps.some((s) => s.status === "failed");
1677
+ const success = !hasFailedStep;
1678
+ return {
1679
+ managerId: this.id,
1680
+ managerName: this.name,
1681
+ icon: this.icon,
1682
+ success,
1683
+ updatedCount: success ? items.length : 0,
1684
+ durationMs: Date.now() - startTime,
1685
+ steps,
1686
+ error: !success ? steps.find((s) => s.status === "failed")?.error : void 0
1687
+ };
1688
+ }
1689
+ };
1690
+
1691
+ // src/managers/gem.ts
1692
+ var RubyGemManager = class extends BasePackageManager {
1693
+ id = "gem";
1694
+ name = "Ruby Gem";
1695
+ icon = "\u{1F48E}";
1696
+ category = "language";
1697
+ binary = "gem";
1698
+ async isAvailable() {
1699
+ const hasCmd = await commandExists(this.binary);
1700
+ if (!hasCmd) return false;
1701
+ const whichRes = await safeExec("which", ["gem"], { timeoutMs: 3e3 });
1702
+ const gemPath = whichRes.stdout.trim();
1703
+ if (gemPath === "/usr/bin/gem" || gemPath.startsWith("/System/Library")) {
1704
+ return false;
1705
+ }
1706
+ return true;
1707
+ }
1708
+ async checkUpdates(options) {
1709
+ const startTime = Date.now();
1710
+ const isAvail = await this.isAvailable();
1711
+ if (!isAvail) {
1712
+ return {
1713
+ managerId: this.id,
1714
+ managerName: this.name,
1715
+ icon: this.icon,
1716
+ category: this.category,
1717
+ available: false,
1718
+ updates: [],
1719
+ durationMs: 0
1720
+ };
1721
+ }
1722
+ try {
1723
+ const execRes = await safeExec("gem", ["outdated"], {
1724
+ timeoutMs: options?.timeoutMs ?? 2e4
1725
+ });
1726
+ const updates = [];
1727
+ const lines = execRes.stdout.split("\n");
1728
+ for (const line of lines) {
1729
+ const trimmed = line.trim();
1730
+ const match = trimmed.match(/^(\S+)\s*\(([^<]+)<\s*([^)]+)\)/);
1731
+ if (match) {
1732
+ const [, name, current, latest] = match;
1733
+ updates.push({
1734
+ managerId: this.id,
1735
+ managerName: this.name,
1736
+ name: name.trim(),
1737
+ currentVersion: current.trim(),
1738
+ latestVersion: latest.trim(),
1739
+ type: "gem"
1740
+ });
1741
+ }
1742
+ }
1743
+ return {
1744
+ managerId: this.id,
1745
+ managerName: this.name,
1746
+ icon: this.icon,
1747
+ category: this.category,
1748
+ available: true,
1749
+ updates,
1750
+ durationMs: Date.now() - startTime
1751
+ };
1752
+ } catch (err) {
1753
+ return {
1754
+ managerId: this.id,
1755
+ managerName: this.name,
1756
+ icon: this.icon,
1757
+ category: this.category,
1758
+ available: true,
1759
+ updates: [],
1760
+ durationMs: Date.now() - startTime,
1761
+ error: err instanceof Error ? err.message : String(err)
1762
+ };
1763
+ }
1764
+ }
1765
+ async executeUpdate(items, options) {
1766
+ const startTime = Date.now();
1767
+ const steps = [];
1768
+ const updateStep = await this.executeStep(
1769
+ "gem update --user-install",
1770
+ "gem",
1771
+ ["update", "--user-install"],
1772
+ options
1773
+ );
1774
+ steps.push(updateStep);
1775
+ const cleanupStep = await this.executeStep(
1776
+ "gem cleanup",
1777
+ "gem",
1778
+ ["cleanup"],
1779
+ options
1780
+ );
1781
+ steps.push(cleanupStep);
1782
+ const success = steps.every((s) => s.status === "success");
1783
+ return {
1784
+ managerId: this.id,
1785
+ managerName: this.name,
1786
+ icon: this.icon,
1787
+ success,
1788
+ updatedCount: success ? items.length : 0,
1789
+ durationMs: Date.now() - startTime,
1790
+ steps,
1791
+ error: !success ? steps.find((s) => s.status === "failed")?.error : void 0
1792
+ };
1793
+ }
1794
+ };
1795
+
1796
+ // src/managers/macports.ts
1797
+ var MacPortsManager = class extends BasePackageManager {
1798
+ id = "macports";
1799
+ name = "MacPorts";
1800
+ icon = "\u2693";
1801
+ category = "system";
1802
+ requiresSudo = true;
1803
+ binary = "port";
1804
+ async checkUpdates(options) {
1805
+ const startTime = Date.now();
1806
+ const isAvail = await this.isAvailable();
1807
+ if (!isAvail) {
1808
+ return {
1809
+ managerId: this.id,
1810
+ managerName: this.name,
1811
+ icon: this.icon,
1812
+ category: this.category,
1813
+ available: false,
1814
+ updates: [],
1815
+ durationMs: 0
1816
+ };
1817
+ }
1818
+ try {
1819
+ const execRes = await safeExec("port", ["outdated"], {
1820
+ timeoutMs: options?.timeoutMs ?? 2e4
1821
+ });
1822
+ const updates = [];
1823
+ const lines = execRes.stdout.split("\n");
1824
+ for (const line of lines) {
1825
+ const trimmed = line.trim();
1826
+ if (!trimmed || trimmed.startsWith("The following") || trimmed.startsWith("--")) {
1827
+ continue;
1828
+ }
1829
+ const match = trimmed.match(/^(\S+)\s+(\S+)\s*<\s*(\S+)/);
1830
+ if (match) {
1831
+ const [, name, current, latest] = match;
1832
+ updates.push({
1833
+ managerId: this.id,
1834
+ managerName: this.name,
1835
+ name,
1836
+ currentVersion: current,
1837
+ latestVersion: latest,
1838
+ type: "port"
1839
+ });
1840
+ }
1841
+ }
1842
+ return {
1843
+ managerId: this.id,
1844
+ managerName: this.name,
1845
+ icon: this.icon,
1846
+ category: this.category,
1847
+ available: true,
1848
+ updates,
1849
+ durationMs: Date.now() - startTime
1850
+ };
1851
+ } catch (err) {
1852
+ return {
1853
+ managerId: this.id,
1854
+ managerName: this.name,
1855
+ icon: this.icon,
1856
+ category: this.category,
1857
+ available: true,
1858
+ updates: [],
1859
+ durationMs: Date.now() - startTime,
1860
+ error: err instanceof Error ? err.message : String(err)
1861
+ };
1862
+ }
1863
+ }
1864
+ async executeUpdate(items, options) {
1865
+ const startTime = Date.now();
1866
+ const steps = [];
1867
+ if (!options.dryRun && process.getuid?.() !== 0) {
1868
+ if (!await this.hasPasswordlessSudo()) {
1869
+ const error = `MacPorts requires sudo privileges. ${SUDO_REQUIRED_HINT}`;
1870
+ return {
1871
+ managerId: this.id,
1383
1872
  managerName: this.name,
1384
1873
  icon: this.icon,
1385
1874
  success: false,
@@ -1390,10 +1879,10 @@ var MacPortsManager = class extends BasePackageManager {
1390
1879
  name: "sudo privileges",
1391
1880
  command: "sudo -n true",
1392
1881
  status: "failed",
1393
- error: 'MacPorts requires sudo privileges. Run "sudo -v" in your terminal before hmd.'
1882
+ error
1394
1883
  }
1395
1884
  ],
1396
- error: 'MacPorts requires sudo privileges. Run "sudo -v" before running hmd.'
1885
+ error
1397
1886
  };
1398
1887
  }
1399
1888
  }
@@ -1513,107 +2002,13 @@ var MacAppStoreManager = class extends BasePackageManager {
1513
2002
  }
1514
2003
  };
1515
2004
 
1516
- // src/utils/formatting.ts
1517
- import pc from "picocolors";
1518
- import Table from "cli-table3";
1519
- function formatDuration(ms) {
1520
- if (ms < 1e3) return `${ms}ms`;
1521
- const seconds = (ms / 1e3).toFixed(1);
1522
- return `${seconds}s`;
1523
- }
1524
- function renderUpdatesTable(items) {
1525
- if (items.length === 0) {
1526
- return pc.dim(" No updates pending.");
1527
- }
1528
- const table = new Table({
1529
- head: [
1530
- pc.dim("Manager"),
1531
- pc.dim("Package"),
1532
- pc.dim("Type"),
1533
- pc.dim("Version Transition")
1534
- ],
1535
- style: {
1536
- head: [],
1537
- border: ["dim"]
1538
- },
1539
- chars: {
1540
- top: "\u2500",
1541
- "top-mid": "\u2500",
1542
- "top-left": "\u250C",
1543
- "top-right": "\u2510",
1544
- bottom: "\u2500",
1545
- "bottom-mid": "\u2500",
1546
- "bottom-left": "\u2514",
1547
- "bottom-right": "\u2518",
1548
- left: "\u2502",
1549
- "left-mid": "\u251C",
1550
- mid: "\u2500",
1551
- "mid-mid": "\u253C",
1552
- right: "\u2502",
1553
- "right-mid": "\u2524",
1554
- middle: "\u2502"
1555
- }
1556
- });
1557
- for (const item of items) {
1558
- const typeLabel = item.type ? pc.dim(`[${item.type}]`) : pc.dim("-");
1559
- const currentVer = pc.yellow(item.currentVersion || "?");
1560
- const arrow = pc.dim(" \u2192 ");
1561
- const latestVer = pc.green(pc.bold(item.latestVersion || "?"));
1562
- table.push([
1563
- pc.cyan(item.managerName),
1564
- pc.bold(pc.white(item.name)),
1565
- typeLabel,
1566
- `${currentVer}${arrow}${latestVer}`
1567
- ]);
1568
- }
1569
- return table.toString();
1570
- }
1571
- function renderExecutionSummary(results) {
1572
- const total = results.length;
1573
- const successful = results.filter((r) => r.success).length;
1574
- const failed = results.filter((r) => !r.success).length;
1575
- const totalUpdated = results.reduce((acc, r) => acc + r.updatedCount, 0);
1576
- const lines = [];
1577
- lines.push("");
1578
- lines.push(pc.dim("\u2500".repeat(60)));
1579
- lines.push(` ${pc.bold(pc.cyan("Heimdall"))} ${pc.dim("\u2022 Execution Summary")}`);
1580
- lines.push(pc.dim("\u2500".repeat(60)));
1581
- for (const res of results) {
1582
- const icon = res.icon || "\u{1F4E6}";
1583
- const status = res.success ? pc.green("\u2714 SUCCESS") : pc.red("\u2716 FAILED");
1584
- const time = pc.dim(`(${formatDuration(res.durationMs)})`);
1585
- 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)`);
1586
- lines.push(` ${icon} ${pc.bold(res.managerName)} ${status} ${count} ${time}`);
1587
- if (!res.success && res.error) {
1588
- lines.push(` ${pc.red("\u21B3 Error:")} ${pc.dim(res.error.trim().split("\n")[0])}`);
1589
- }
1590
- if (res.steps.length > 0) {
1591
- for (const step of res.steps) {
1592
- if (step.status === "skipped") continue;
1593
- const stepStatus = step.status === "success" ? pc.green("\u2714") : step.status === "failed" ? pc.red("\u2716") : pc.dim("\u25CB");
1594
- const stepDuration = step.durationMs ? pc.dim(`[${formatDuration(step.durationMs)}]`) : "";
1595
- const stepError = step.error && step.status === "failed" ? pc.red(` (${step.error.split("\n")[0]})`) : "";
1596
- lines.push(` ${stepStatus} ${pc.dim(step.name)} ${stepDuration}${stepError}`);
1597
- }
1598
- }
1599
- }
1600
- lines.push(pc.dim("\u2500".repeat(60)));
1601
- const summaryParts = [
1602
- pc.green(`${successful}/${total} managers succeeded`),
1603
- failed > 0 ? pc.red(`${failed} failed`) : null,
1604
- pc.cyan(`${totalUpdated} package(s) updated`)
1605
- ].filter(Boolean);
1606
- lines.push(` ${summaryParts.join(" \u2022 ")}`);
1607
- lines.push(pc.dim("\u2500".repeat(60)));
1608
- return lines.join("\n");
1609
- }
1610
-
1611
2005
  // src/managers/linux.ts
1612
2006
  var AptManager = class extends BasePackageManager {
1613
2007
  id = "apt";
1614
2008
  name = "APT (Debian/Ubuntu)";
1615
2009
  icon = "\u{1F427}";
1616
2010
  category = "system";
2011
+ requiresSudo = true;
1617
2012
  binary = "apt-get";
1618
2013
  async checkUpdates(options) {
1619
2014
  const startTime = Date.now();
@@ -1676,8 +2071,8 @@ var AptManager = class extends BasePackageManager {
1676
2071
  const startTime = Date.now();
1677
2072
  const steps = [];
1678
2073
  if (!options.dryRun && process.getuid?.() !== 0) {
1679
- const sudoCheck = await safeExec("sudo", ["-n", "true"], { timeoutMs: 3e3 });
1680
- if (!sudoCheck.success) {
2074
+ if (!await this.hasPasswordlessSudo()) {
2075
+ const error = `APT requires sudo privileges. ${SUDO_REQUIRED_HINT}`;
1681
2076
  return {
1682
2077
  managerId: this.id,
1683
2078
  managerName: this.name,
@@ -1690,10 +2085,10 @@ var AptManager = class extends BasePackageManager {
1690
2085
  name: "sudo privileges",
1691
2086
  command: "sudo -n true",
1692
2087
  status: "failed",
1693
- error: 'APT requires sudo privileges. Run "sudo -v" in your terminal before hmd.'
2088
+ error
1694
2089
  }
1695
2090
  ],
1696
- error: 'APT requires sudo privileges. Run "sudo -v" before running hmd.'
2091
+ error
1697
2092
  };
1698
2093
  }
1699
2094
  }
@@ -1707,18 +2102,54 @@ var AptManager = class extends BasePackageManager {
1707
2102
  const upgradeStep = await this.executeStep(
1708
2103
  "sudo apt-get upgrade",
1709
2104
  "sudo",
1710
- ["apt-get", "upgrade", "-y"],
2105
+ ["apt-get", "upgrade", "-y", "--with-new-pkgs"],
1711
2106
  options
1712
2107
  );
1713
2108
  steps.push(upgradeStep);
1714
- const cleanupStep = await this.executeStep(
1715
- "sudo apt-get autoremove",
1716
- "sudo",
1717
- ["apt-get", "autoremove", "-y"],
1718
- options
1719
- );
1720
- steps.push(cleanupStep);
1721
- const success = steps.every((s) => s.status === "success");
2109
+ if (upgradeStep.status === "success") {
2110
+ if (options.dryRun) {
2111
+ const autoremoveStep = await this.executeStep(
2112
+ "sudo apt-get autoremove",
2113
+ "sudo",
2114
+ ["apt-get", "autoremove", "-y"],
2115
+ options
2116
+ );
2117
+ steps.push(autoremoveStep);
2118
+ } else if (options.confirmAction) {
2119
+ const shouldAutoremove = await options.confirmAction(
2120
+ "Do you want to run sudo apt-get autoremove to clean up orphaned packages?"
2121
+ );
2122
+ if (shouldAutoremove) {
2123
+ const autoremoveStep = await this.executeStep(
2124
+ "sudo apt-get autoremove",
2125
+ "sudo",
2126
+ ["apt-get", "autoremove", "-y"],
2127
+ options
2128
+ );
2129
+ steps.push(autoremoveStep);
2130
+ } else {
2131
+ steps.push({
2132
+ name: "sudo apt-get autoremove",
2133
+ command: "sudo apt-get autoremove -y",
2134
+ status: "skipped"
2135
+ });
2136
+ }
2137
+ } else {
2138
+ steps.push({
2139
+ name: "sudo apt-get autoremove",
2140
+ command: "sudo apt-get autoremove -y",
2141
+ status: "skipped"
2142
+ });
2143
+ }
2144
+ } else {
2145
+ steps.push({
2146
+ name: "sudo apt-get autoremove",
2147
+ command: "sudo apt-get autoremove -y",
2148
+ status: "skipped"
2149
+ });
2150
+ }
2151
+ const executedSteps = steps.filter((s) => s.status !== "skipped");
2152
+ const success = executedSteps.length > 0 && executedSteps.every((s) => s.status === "success");
1722
2153
  return {
1723
2154
  managerId: this.id,
1724
2155
  managerName: this.name,
@@ -1736,6 +2167,7 @@ var FlatpakManager = class extends BasePackageManager {
1736
2167
  name = "Flatpak";
1737
2168
  icon = "\u{1F4E6}";
1738
2169
  category = "system";
2170
+ requiresSudo = true;
1739
2171
  binary = "flatpak";
1740
2172
  async checkUpdates(options) {
1741
2173
  const startTime = Date.now();
@@ -1752,23 +2184,43 @@ var FlatpakManager = class extends BasePackageManager {
1752
2184
  };
1753
2185
  }
1754
2186
  try {
1755
- const execRes = await safeExec("flatpak", ["remote-ls", "--updates"], {
1756
- timeoutMs: options?.timeoutMs ?? 2e4
1757
- });
1758
- const updates = [];
1759
- const lines = execRes.stdout.split("\n");
1760
- for (const line of lines) {
1761
- const trimmed = line.trim();
1762
- if (!trimmed) continue;
1763
- const parts = trimmed.split(/\s+/);
1764
- updates.push({
2187
+ const timeoutMs = options?.timeoutMs ?? 2e4;
2188
+ const [userRes, systemRes] = await Promise.all([
2189
+ safeExec("flatpak", ["remote-ls", "--user", "--updates"], { timeoutMs }),
2190
+ safeExec("flatpak", ["remote-ls", "--system", "--updates"], { timeoutMs })
2191
+ ]);
2192
+ if (!userRes.success && !systemRes.success) {
2193
+ const raw = (systemRes.stderr || userRes.stderr).trim();
2194
+ return {
1765
2195
  managerId: this.id,
1766
2196
  managerName: this.name,
1767
- name: parts[0],
1768
- currentVersion: "installed",
1769
- latestVersion: parts[1] ?? "latest",
1770
- type: "flatpak"
1771
- });
2197
+ icon: this.icon,
2198
+ category: this.category,
2199
+ available: true,
2200
+ updates: [],
2201
+ durationMs: Date.now() - startTime,
2202
+ error: raw ? extractErrorMessage(raw) : void 0
2203
+ };
2204
+ }
2205
+ const updates = [];
2206
+ const seen = /* @__PURE__ */ new Set();
2207
+ for (const execRes of [userRes, systemRes]) {
2208
+ for (const line of execRes.stdout.split("\n")) {
2209
+ const trimmed = line.trim();
2210
+ if (!trimmed) continue;
2211
+ const parts = trimmed.split(/\s+/);
2212
+ const name = parts[0];
2213
+ if (seen.has(name)) continue;
2214
+ seen.add(name);
2215
+ updates.push({
2216
+ managerId: this.id,
2217
+ managerName: this.name,
2218
+ name,
2219
+ currentVersion: "installed",
2220
+ latestVersion: parts[1] ?? "latest",
2221
+ type: "flatpak"
2222
+ });
2223
+ }
1772
2224
  }
1773
2225
  return {
1774
2226
  managerId: this.id,
@@ -1792,17 +2244,56 @@ var FlatpakManager = class extends BasePackageManager {
1792
2244
  };
1793
2245
  }
1794
2246
  }
2247
+ async hasSystemScopeApps() {
2248
+ const result = await safeExec("flatpak", ["list", "--system", "--columns=application"], {
2249
+ timeoutMs: 1e4
2250
+ });
2251
+ return result.success && result.stdout.trim().length > 0;
2252
+ }
1795
2253
  async executeUpdate(items, options) {
1796
2254
  const startTime = Date.now();
1797
2255
  const steps = [];
1798
- const updateStep = await this.executeStep(
1799
- "flatpak update -y",
2256
+ const userStep = await this.executeStep(
2257
+ "flatpak update --user",
1800
2258
  "flatpak",
1801
- ["update", "-y", "--noninteractive"],
2259
+ ["update", "--user", "-y", "--noninteractive"],
1802
2260
  options
1803
2261
  );
1804
- steps.push(updateStep);
1805
- const success = steps.every((s) => s.status === "success");
2262
+ steps.push(userStep);
2263
+ const sudoStepName = "sudo flatpak update --system";
2264
+ const sudoStepCommand = "sudo flatpak update --system -y --noninteractive";
2265
+ if (options.dryRun) {
2266
+ const systemStep = await this.executeStep(
2267
+ sudoStepName,
2268
+ "sudo",
2269
+ ["flatpak", "update", "--system", "-y", "--noninteractive"],
2270
+ options
2271
+ );
2272
+ steps.push(systemStep);
2273
+ } else if (process.getuid?.() === 0 || await this.hasPasswordlessSudo()) {
2274
+ const systemStep = await this.executeStep(
2275
+ sudoStepName,
2276
+ "sudo",
2277
+ ["flatpak", "update", "--system", "-y", "--noninteractive"],
2278
+ options
2279
+ );
2280
+ steps.push(systemStep);
2281
+ } else if (await this.hasSystemScopeApps()) {
2282
+ steps.push({
2283
+ name: sudoStepName,
2284
+ command: sudoStepCommand,
2285
+ status: "failed",
2286
+ error: `System-wide Flatpak updates require sudo privileges. ${SUDO_REQUIRED_HINT}`
2287
+ });
2288
+ } else {
2289
+ steps.push({
2290
+ name: sudoStepName,
2291
+ command: sudoStepCommand,
2292
+ status: "skipped"
2293
+ });
2294
+ }
2295
+ const executedSteps = steps.filter((s) => s.status !== "skipped");
2296
+ const success = executedSteps.length > 0 && executedSteps.every((s) => s.status === "success");
1806
2297
  return {
1807
2298
  managerId: this.id,
1808
2299
  managerName: this.name,
@@ -1820,6 +2311,7 @@ var DnfManager = class extends BasePackageManager {
1820
2311
  name = "DNF (Fedora/RHEL)";
1821
2312
  icon = "\u{1F4E6}";
1822
2313
  category = "system";
2314
+ requiresSudo = true;
1823
2315
  binary = "dnf";
1824
2316
  async checkUpdates(options) {
1825
2317
  const startTime = Date.now();
@@ -1917,8 +2409,8 @@ var DnfManager = class extends BasePackageManager {
1917
2409
  const startTime = Date.now();
1918
2410
  const steps = [];
1919
2411
  if (!options.dryRun && process.getuid?.() !== 0) {
1920
- const sudoCheck = await safeExec("sudo", ["-n", "true"], { timeoutMs: 3e3 });
1921
- if (!sudoCheck.success) {
2412
+ if (!await this.hasPasswordlessSudo()) {
2413
+ const error = `DNF requires sudo privileges. ${SUDO_REQUIRED_HINT}`;
1922
2414
  return {
1923
2415
  managerId: this.id,
1924
2416
  managerName: this.name,
@@ -1931,10 +2423,10 @@ var DnfManager = class extends BasePackageManager {
1931
2423
  name: "sudo privileges",
1932
2424
  command: "sudo -n true",
1933
2425
  status: "failed",
1934
- error: 'DNF requires sudo privileges. Run "sudo -v" before running hmd.'
2426
+ error
1935
2427
  }
1936
2428
  ],
1937
- error: 'DNF requires sudo privileges. Run "sudo -v" before running hmd.'
2429
+ error
1938
2430
  };
1939
2431
  }
1940
2432
  }
@@ -2002,6 +2494,170 @@ var DnfManager = class extends BasePackageManager {
2002
2494
  }
2003
2495
  };
2004
2496
 
2497
+ // src/managers/firmware.ts
2498
+ var FirmwareManager = class extends BasePackageManager {
2499
+ id = "firmware";
2500
+ name = "Firmware (fwupd)";
2501
+ icon = "\u{1F527}";
2502
+ category = "system";
2503
+ requiresSudo = true;
2504
+ binary = "fwupdmgr";
2505
+ async isAvailable() {
2506
+ if (process.platform !== "linux") return false;
2507
+ return commandExists("fwupdmgr");
2508
+ }
2509
+ parseUpdates(stdout) {
2510
+ const jsonStart = stdout.indexOf("{");
2511
+ const jsonEnd = stdout.lastIndexOf("}");
2512
+ if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) return [];
2513
+ let parsed;
2514
+ try {
2515
+ parsed = JSON.parse(stdout.slice(jsonStart, jsonEnd + 1));
2516
+ } catch {
2517
+ return [];
2518
+ }
2519
+ const updates = [];
2520
+ for (const device of parsed.Devices ?? []) {
2521
+ const latestVersion = device.Releases?.[0]?.Version;
2522
+ if (!device.Name || !latestVersion) continue;
2523
+ updates.push({
2524
+ managerId: this.id,
2525
+ managerName: this.name,
2526
+ name: device.Name,
2527
+ currentVersion: device.Version ?? "unknown",
2528
+ latestVersion,
2529
+ type: "firmware",
2530
+ extra: device.Vendor ?? device.Plugin
2531
+ });
2532
+ }
2533
+ return updates;
2534
+ }
2535
+ async checkUpdates(options) {
2536
+ const startTime = Date.now();
2537
+ const isAvail = await this.isAvailable();
2538
+ if (!isAvail) {
2539
+ return {
2540
+ managerId: this.id,
2541
+ managerName: this.name,
2542
+ icon: this.icon,
2543
+ category: this.category,
2544
+ available: false,
2545
+ updates: [],
2546
+ durationMs: 0
2547
+ };
2548
+ }
2549
+ try {
2550
+ const timeoutMs = options?.timeoutMs ?? 3e4;
2551
+ await safeExec("fwupdmgr", ["refresh"], { timeoutMs: Math.min(timeoutMs, 2e4) });
2552
+ const execRes = await safeExec("fwupdmgr", ["--json", "get-updates"], { timeoutMs });
2553
+ const updates = this.parseUpdates(execRes.stdout);
2554
+ const parsedOutput = execRes.stdout.trim().startsWith("{");
2555
+ if (!execRes.success && !parsedOutput) {
2556
+ const raw = (execRes.stderr || execRes.stdout).trim();
2557
+ return {
2558
+ managerId: this.id,
2559
+ managerName: this.name,
2560
+ icon: this.icon,
2561
+ category: this.category,
2562
+ available: true,
2563
+ updates: [],
2564
+ durationMs: Date.now() - startTime,
2565
+ error: raw ? extractErrorMessage(raw) : "fwupdmgr get-updates failed"
2566
+ };
2567
+ }
2568
+ return {
2569
+ managerId: this.id,
2570
+ managerName: this.name,
2571
+ icon: this.icon,
2572
+ category: this.category,
2573
+ available: true,
2574
+ updates,
2575
+ durationMs: Date.now() - startTime
2576
+ };
2577
+ } catch (err) {
2578
+ return {
2579
+ managerId: this.id,
2580
+ managerName: this.name,
2581
+ icon: this.icon,
2582
+ category: this.category,
2583
+ available: true,
2584
+ updates: [],
2585
+ durationMs: Date.now() - startTime,
2586
+ error: err instanceof Error ? err.message : String(err)
2587
+ };
2588
+ }
2589
+ }
2590
+ async executeUpdate(items, options) {
2591
+ const startTime = Date.now();
2592
+ const steps = [];
2593
+ if (items.length === 0) {
2594
+ return {
2595
+ managerId: this.id,
2596
+ managerName: this.name,
2597
+ icon: this.icon,
2598
+ success: true,
2599
+ updatedCount: 0,
2600
+ durationMs: Date.now() - startTime,
2601
+ steps
2602
+ };
2603
+ }
2604
+ if (!options.dryRun && process.getuid?.() !== 0) {
2605
+ if (!await this.hasPasswordlessSudo()) {
2606
+ const error = `Firmware updates require sudo privileges. ${SUDO_REQUIRED_HINT}`;
2607
+ return {
2608
+ managerId: this.id,
2609
+ managerName: this.name,
2610
+ icon: this.icon,
2611
+ success: false,
2612
+ updatedCount: 0,
2613
+ durationMs: Date.now() - startTime,
2614
+ steps: [
2615
+ {
2616
+ name: "sudo privileges",
2617
+ command: "sudo -n true",
2618
+ status: "failed",
2619
+ error
2620
+ }
2621
+ ],
2622
+ error
2623
+ };
2624
+ }
2625
+ }
2626
+ const refreshStep = await this.executeStep(
2627
+ "sudo fwupdmgr refresh",
2628
+ "sudo",
2629
+ ["fwupdmgr", "refresh", "--force"],
2630
+ options
2631
+ );
2632
+ steps.push(refreshStep);
2633
+ const updateStep = await this.executeStep(
2634
+ "sudo fwupdmgr update",
2635
+ "sudo",
2636
+ [
2637
+ "fwupdmgr",
2638
+ "update",
2639
+ "-y",
2640
+ "--no-reboot-check",
2641
+ "--no-unreported-check",
2642
+ "--no-metadata-check"
2643
+ ],
2644
+ options
2645
+ );
2646
+ steps.push(updateStep);
2647
+ const success = steps.every((s) => s.status === "success");
2648
+ return {
2649
+ managerId: this.id,
2650
+ managerName: this.name,
2651
+ icon: this.icon,
2652
+ success,
2653
+ updatedCount: success ? items.length : 0,
2654
+ durationMs: Date.now() - startTime,
2655
+ steps,
2656
+ error: !success ? steps.find((s) => s.status === "failed")?.error : void 0
2657
+ };
2658
+ }
2659
+ };
2660
+
2005
2661
  // src/managers/registry.ts
2006
2662
  function createDefaultManagers() {
2007
2663
  return [
@@ -2012,13 +2668,17 @@ function createDefaultManagers() {
2012
2668
  new YarnManager(),
2013
2669
  new PythonPipManager(),
2014
2670
  new PipxManager(),
2671
+ new UvManager(),
2672
+ new PoetryManager(),
2673
+ new CondaManager(),
2015
2674
  new CargoManager(),
2016
2675
  new RubyGemManager(),
2017
2676
  new MacPortsManager(),
2018
2677
  new MacAppStoreManager(),
2019
2678
  new AptManager(),
2020
2679
  new DnfManager(),
2021
- new FlatpakManager()
2680
+ new FlatpakManager(),
2681
+ new FirmwareManager()
2022
2682
  ];
2023
2683
  }
2024
2684
  function filterManagers(managers, only, exclude) {
@@ -2154,10 +2814,260 @@ var UpdaterEngine = class {
2154
2814
  }
2155
2815
  };
2156
2816
 
2817
+ // src/core/distro.ts
2818
+ import { readFileSync } from "fs";
2819
+ var FEDORA_RELEASES_URL = "https://bodhi.fedoraproject.org/releases?state=current";
2820
+ var UBUNTU_META_RELEASE_URL = "https://changelogs.ubuntu.com/meta-release";
2821
+ var DEBIAN_STABLE_RELEASE_URL = "https://deb.debian.org/debian/dists/stable/Release";
2822
+ function parseOsRelease(content) {
2823
+ const values = {};
2824
+ for (const rawLine of content.split("\n")) {
2825
+ const line = rawLine.trim();
2826
+ if (!line || line.startsWith("#")) continue;
2827
+ const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
2828
+ if (!match) continue;
2829
+ values[match[1]] = match[2].replace(/^"(.*)"$/, "$1");
2830
+ }
2831
+ const id = values.ID?.toLowerCase();
2832
+ if (!id) return null;
2833
+ return {
2834
+ id,
2835
+ idLike: (values.ID_LIKE ?? "").toLowerCase().split(/\s+/).filter(Boolean),
2836
+ versionId: values.VERSION_ID ?? "",
2837
+ codename: values.VERSION_CODENAME ?? "",
2838
+ prettyName: values.PRETTY_NAME ?? values.NAME ?? id
2839
+ };
2840
+ }
2841
+ function readDistroIdentity() {
2842
+ try {
2843
+ return parseOsRelease(readFileSync("/etc/os-release", "utf8"));
2844
+ } catch {
2845
+ return null;
2846
+ }
2847
+ }
2848
+ function numericVersion(value) {
2849
+ const parsed = Number.parseFloat(value);
2850
+ return Number.isFinite(parsed) ? parsed : void 0;
2851
+ }
2852
+ async function fetchText(url, timeoutMs) {
2853
+ try {
2854
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
2855
+ if (!response.ok) return void 0;
2856
+ return await response.text();
2857
+ } catch {
2858
+ return void 0;
2859
+ }
2860
+ }
2861
+ async function fetchJson(url, timeoutMs) {
2862
+ try {
2863
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
2864
+ if (!response.ok) return void 0;
2865
+ return await response.json();
2866
+ } catch {
2867
+ return void 0;
2868
+ }
2869
+ }
2870
+ async function detectFedora(identity, timeoutMs) {
2871
+ const current = numericVersion(identity.versionId);
2872
+ if (current === void 0) return null;
2873
+ const data = await fetchJson(FEDORA_RELEASES_URL, timeoutMs);
2874
+ const versions = (data?.releases ?? []).filter((release) => release.id_prefix === "FEDORA" && release.version !== void 0).map((release) => numericVersion(release.version ?? "")).filter((version) => version !== void 0 && Number.isInteger(version));
2875
+ if (versions.length === 0) return null;
2876
+ const latest = Math.max(...versions);
2877
+ if (latest <= current) return null;
2878
+ return {
2879
+ family: "fedora",
2880
+ currentVersion: identity.versionId,
2881
+ nextVersion: String(latest),
2882
+ targetName: `Fedora Linux ${latest}`,
2883
+ supported: true,
2884
+ steps: [
2885
+ {
2886
+ name: `sudo dnf system-upgrade download --releasever=${latest}`,
2887
+ file: "sudo",
2888
+ args: ["dnf", "system-upgrade", "download", `--releasever=${latest}`, "-y"],
2889
+ requiresSudo: true
2890
+ }
2891
+ ],
2892
+ rebootRequired: true,
2893
+ rebootCommand: "sudo dnf system-upgrade reboot",
2894
+ guidance: [
2895
+ `Packages for Fedora ${latest} are downloaded and staged; the upgrade runs offline during the next boot.`,
2896
+ `Reboot to apply it: sudo dnf system-upgrade reboot`,
2897
+ "If the system-upgrade plugin is missing, install `dnf-plugin-system-upgrade` (dnf4) or `dnf5-plugin-system-upgrade` (dnf5)."
2898
+ ]
2899
+ };
2900
+ }
2901
+ function parseUbuntuMetaRelease(content) {
2902
+ const blocks = [];
2903
+ for (const chunk of content.split(/\n\s*\n/)) {
2904
+ const fields = {};
2905
+ for (const line of chunk.split("\n")) {
2906
+ const match = line.match(/^([A-Za-z-]+):\s*(.*)$/);
2907
+ if (match) fields[match[1].toLowerCase()] = match[2].trim();
2908
+ }
2909
+ if (fields.dist && fields.version) {
2910
+ blocks.push({
2911
+ dist: fields.dist,
2912
+ version: fields.version,
2913
+ supported: fields.supported === "1"
2914
+ });
2915
+ }
2916
+ }
2917
+ return blocks;
2918
+ }
2919
+ async function detectUbuntu(identity, timeoutMs) {
2920
+ const current = numericVersion(identity.versionId);
2921
+ if (current === void 0) return null;
2922
+ if (await commandExists("do-release-upgrade")) {
2923
+ const check = await safeExec("do-release-upgrade", ["-c"], {
2924
+ timeoutMs: Math.max(timeoutMs, 2e4)
2925
+ });
2926
+ const output = `${check.stdout}
2927
+ ${check.stderr}`;
2928
+ const match = output.match(/New release '([^']+)' available/i);
2929
+ if (match) {
2930
+ return {
2931
+ family: "ubuntu",
2932
+ currentVersion: identity.versionId,
2933
+ nextVersion: match[1],
2934
+ targetName: `Ubuntu ${match[1]}`,
2935
+ supported: true,
2936
+ steps: [
2937
+ {
2938
+ name: "sudo do-release-upgrade -f DistUpgradeViewNonInteractive",
2939
+ file: "sudo",
2940
+ args: ["do-release-upgrade", "-f", "DistUpgradeViewNonInteractive"],
2941
+ requiresSudo: true
2942
+ }
2943
+ ],
2944
+ rebootRequired: true,
2945
+ rebootCommand: "sudo reboot",
2946
+ guidance: [
2947
+ "The release upgrade runs non-interactively and may take a long time; reboot when it asks or once it reports completion.",
2948
+ "Make sure your data is backed up before upgrading the distribution."
2949
+ ]
2950
+ };
2951
+ }
2952
+ if (/no new release found/i.test(output)) return null;
2953
+ }
2954
+ const meta = await fetchText(UBUNTU_META_RELEASE_URL, timeoutMs);
2955
+ if (!meta) return null;
2956
+ const candidates = parseUbuntuMetaRelease(meta).filter((release) => release.supported).map((release) => ({ ...release, numeric: numericVersion(release.version) })).filter(
2957
+ (release) => release.numeric !== void 0 && release.numeric > current
2958
+ ).sort((a, b) => a.numeric - b.numeric);
2959
+ const next = candidates[0];
2960
+ if (!next) return null;
2961
+ return {
2962
+ family: "ubuntu",
2963
+ currentVersion: identity.versionId,
2964
+ nextVersion: next.version,
2965
+ targetName: `Ubuntu ${next.version}`,
2966
+ supported: false,
2967
+ steps: [],
2968
+ rebootRequired: true,
2969
+ guidance: [
2970
+ `Ubuntu ${next.version} is available, but the official upgrader is not installed.`,
2971
+ "Install it with: sudo apt install update-manager-core",
2972
+ "Then run: sudo do-release-upgrade"
2973
+ ]
2974
+ };
2975
+ }
2976
+ async function detectDebian(identity, timeoutMs) {
2977
+ const current = numericVersion(identity.versionId);
2978
+ if (current === void 0) return null;
2979
+ const release = await fetchText(DEBIAN_STABLE_RELEASE_URL, timeoutMs);
2980
+ if (!release) return null;
2981
+ const versionMatch = release.match(/^Version:\s*(\S+)$/m);
2982
+ const codenameMatch = release.match(/^Codename:\s*(\S+)$/m);
2983
+ if (!versionMatch) return null;
2984
+ const stable = numericVersion(versionMatch[1]);
2985
+ if (stable === void 0) return null;
2986
+ const stableMajor = Math.trunc(stable);
2987
+ if (stableMajor <= Math.trunc(current)) return null;
2988
+ const codename = codenameMatch?.[1] ?? `debian-${stableMajor}`;
2989
+ const currentCodename = identity.codename || `version ${identity.versionId}`;
2990
+ return {
2991
+ family: "debian",
2992
+ currentVersion: identity.versionId,
2993
+ nextVersion: String(stableMajor),
2994
+ targetName: `Debian ${stableMajor} (${codename})`,
2995
+ // Debian has no supported unattended in-place upgrader: sources.list must switch
2996
+ // suites deliberately, so Heimdall only reports the procedure.
2997
+ supported: false,
2998
+ steps: [],
2999
+ rebootRequired: true,
3000
+ guidance: [
3001
+ `Debian ${stableMajor} (${codename}) is available; this system runs ${identity.versionId} (${currentCodename}).`,
3002
+ `Replace "${currentCodename}" with "${codename}" in /etc/apt/sources.list and /etc/apt/sources.list.d/*.list.`,
3003
+ "Then run: sudo apt update && sudo apt full-upgrade",
3004
+ "Back up your data first and expect to reboot afterwards. Debian does not offer an unattended upgrade path."
3005
+ ]
3006
+ };
3007
+ }
3008
+ async function detectDistroUpgrade(options) {
3009
+ if (process.platform !== "linux") return null;
3010
+ const identity = options?.identity ?? readDistroIdentity();
3011
+ if (!identity) return null;
3012
+ const timeoutMs = options?.timeoutMs ?? 6e3;
3013
+ const family = identity.id === "fedora" ? "fedora" : identity.id === "ubuntu" || identity.idLike.includes("ubuntu") ? "ubuntu" : identity.id === "debian" ? "debian" : void 0;
3014
+ try {
3015
+ if (family === "fedora") return await detectFedora(identity, timeoutMs);
3016
+ if (family === "ubuntu") return await detectUbuntu(identity, timeoutMs);
3017
+ if (family === "debian") return await detectDebian(identity, timeoutMs);
3018
+ return null;
3019
+ } catch {
3020
+ return null;
3021
+ }
3022
+ }
3023
+ async function applyDistroUpgrade(plan, options) {
3024
+ const steps = [];
3025
+ if (!plan.supported || plan.steps.length === 0) {
3026
+ return {
3027
+ success: false,
3028
+ steps,
3029
+ error: `No unattended upgrade routine is available for ${plan.targetName}.`
3030
+ };
3031
+ }
3032
+ if (!options.dryRun && process.getuid?.() !== 0) {
3033
+ const ticket = await safeExec("sudo", ["-n", "true"], { timeoutMs: 3e3 });
3034
+ if (!ticket.success) {
3035
+ const error = `The distribution upgrade requires sudo privileges. Run "sudo -v" in this terminal and retry.`;
3036
+ return {
3037
+ success: false,
3038
+ steps: [
3039
+ {
3040
+ name: "sudo privileges",
3041
+ command: "sudo -n true",
3042
+ status: "failed",
3043
+ error
3044
+ }
3045
+ ],
3046
+ error
3047
+ };
3048
+ }
3049
+ }
3050
+ for (const step of plan.steps) {
3051
+ const result = await runStep(step.name, step.file, step.args, {
3052
+ ...options,
3053
+ // Distribution upgrades are long operations: allow up to one hour per step.
3054
+ timeoutMs: 36e5
3055
+ });
3056
+ steps.push(result);
3057
+ if (result.status !== "success") break;
3058
+ }
3059
+ const success = steps.length > 0 && steps.every((step) => step.status === "success");
3060
+ return {
3061
+ success,
3062
+ steps,
3063
+ error: !success ? steps.find((step) => step.status === "failed")?.error : void 0
3064
+ };
3065
+ }
3066
+
2157
3067
  // src/utils/version.ts
2158
3068
  import { fileURLToPath } from "url";
2159
3069
  import { dirname, resolve } from "path";
2160
- import { existsSync, readFileSync } from "fs";
3070
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
2161
3071
  var FALLBACK_METADATA = {
2162
3072
  name: "@dahch/heimdall",
2163
3073
  version: "1.0.1",
@@ -2167,7 +3077,7 @@ var cachedMetadata = null;
2167
3077
  var customFsAdapter = null;
2168
3078
  function resolvePackageMetadata(options) {
2169
3079
  const exists = options?.fsAdapter?.existsSync ?? customFsAdapter?.existsSync ?? existsSync;
2170
- const read = options?.fsAdapter?.readFileSync ?? customFsAdapter?.readFileSync ?? readFileSync;
3080
+ const read = options?.fsAdapter?.readFileSync ?? customFsAdapter?.readFileSync ?? readFileSync2;
2171
3081
  try {
2172
3082
  let currentDir;
2173
3083
  if (options?.basePath) {
@@ -2220,10 +3130,127 @@ function getPackageMetadata(options) {
2220
3130
  }
2221
3131
 
2222
3132
  // src/cli.ts
3133
+ async function offerDistroUpgrade(options, timeoutMs, isSimulated) {
3134
+ if (process.platform !== "linux" || options.distroUpgrade === false) return true;
3135
+ const scanSpinner = p.spinner();
3136
+ scanSpinner.start("Checking for distribution release upgrades...");
3137
+ let plan = null;
3138
+ try {
3139
+ plan = await detectDistroUpgrade({ timeoutMs: Math.max(timeoutMs, 6e3) });
3140
+ } catch {
3141
+ plan = null;
3142
+ }
3143
+ scanSpinner.stop(
3144
+ plan ? `Distribution upgrade available: ${plan.targetName}` : "Distribution release is up to date"
3145
+ );
3146
+ if (!plan) {
3147
+ if (options.verbose) {
3148
+ p.log.step(pc2.dim("No newer distribution release detected."));
3149
+ }
3150
+ return true;
3151
+ }
3152
+ p.note(
3153
+ [
3154
+ `${pc2.bold(plan.targetName)} is available ${pc2.dim(`(current: ${plan.currentVersion})`)}.`,
3155
+ plan.supported ? "Heimdall can stage/apply the upgrade; it never reboots the machine by itself." : "Heimdall cannot apply this upgrade automatically."
3156
+ ].join("\n"),
3157
+ "Distribution upgrade available"
3158
+ );
3159
+ for (const line of plan.guidance) {
3160
+ p.log.step(pc2.dim(`\u21B3 ${line}`));
3161
+ }
3162
+ if (isSimulated) {
3163
+ for (const step of plan.steps) {
3164
+ p.log.info(pc2.yellow(`[DRY RUN] Would run: ${[step.file, ...step.args].join(" ")}`));
3165
+ }
3166
+ return true;
3167
+ }
3168
+ if (!plan.supported) return true;
3169
+ const autoApply = options.upgradeDistro === true;
3170
+ const canPrompt = Boolean(process.stdin.isTTY) && !options.yes && !autoApply;
3171
+ if (!autoApply && !canPrompt) {
3172
+ const [first] = plan.steps;
3173
+ if (first) {
3174
+ p.log.info(
3175
+ pc2.dim(`Not applied automatically. Run: ${[first.file, ...first.args].join(" ")}`)
3176
+ );
3177
+ }
3178
+ return true;
3179
+ }
3180
+ let proceed = autoApply;
3181
+ if (!autoApply) {
3182
+ const answer = await p.confirm({
3183
+ message: `Apply the ${plan.targetName} release upgrade now?`,
3184
+ initialValue: false
3185
+ });
3186
+ if (p.isCancel(answer)) {
3187
+ p.cancel("Distribution upgrade cancelled.");
3188
+ return true;
3189
+ }
3190
+ proceed = answer;
3191
+ }
3192
+ if (!proceed) {
3193
+ p.log.info(pc2.yellow("Distribution upgrade skipped."));
3194
+ return true;
3195
+ }
3196
+ const upgradeSpinner = p.spinner();
3197
+ let stopSudoKeepAlive;
3198
+ if (process.getuid?.() !== 0) {
3199
+ p.log.step(pc2.dim("Requesting sudo privileges for the distribution upgrade..."));
3200
+ if (!await ensureSudo()) {
3201
+ p.log.warn(
3202
+ pc2.yellow(
3203
+ 'Sudo privileges not granted: the distribution upgrade cannot run. Run "sudo -v" in this terminal and retry.'
3204
+ )
3205
+ );
3206
+ return false;
3207
+ }
3208
+ stopSudoKeepAlive = startSudoKeepAlive();
3209
+ }
3210
+ upgradeSpinner.start(pc2.dim("Running the distribution release upgrade..."));
3211
+ let result;
3212
+ try {
3213
+ result = await applyDistroUpgrade(plan, {
3214
+ onStepStart: (stepName) => upgradeSpinner.message(pc2.dim(`Running: ${stepName}...`)),
3215
+ onStepProgress: (_stepName, logLine) => {
3216
+ if (!logLine) return;
3217
+ const truncated = logLine.length > 55 ? `${logLine.slice(0, 52)}...` : logLine;
3218
+ upgradeSpinner.message(pc2.dim(truncated));
3219
+ },
3220
+ onStepEnd: (stepName, success, error, durationMs) => {
3221
+ const mark = success ? pc2.green("\u2714") : pc2.red("\u2716");
3222
+ const durStr = durationMs !== void 0 ? pc2.dim(` [${formatDuration(durationMs)}]`) : "";
3223
+ const detail = error ? pc2.red(` \u21B3 ${error.split("\n")[0]}`) : "";
3224
+ upgradeSpinner.stop(` ${mark} ${pc2.dim(stepName)}${durStr}${detail}`);
3225
+ upgradeSpinner.start(pc2.dim("Working..."));
3226
+ }
3227
+ });
3228
+ } finally {
3229
+ stopSudoKeepAlive?.();
3230
+ }
3231
+ upgradeSpinner.stop(
3232
+ result.success ? pc2.green("\u2714 Distribution upgrade routine completed") : pc2.red("\u2716 Distribution upgrade failed")
3233
+ );
3234
+ if (!result.success) {
3235
+ if (result.error) p.log.error(pc2.red(result.error));
3236
+ return false;
3237
+ }
3238
+ if (plan.rebootRequired) {
3239
+ p.note(
3240
+ `Reboot to finish the upgrade:
3241
+ ${pc2.bold(plan.rebootCommand ?? "sudo reboot")}`,
3242
+ "Reboot required"
3243
+ );
3244
+ }
3245
+ return true;
3246
+ }
2223
3247
  async function main() {
2224
3248
  const { version, description } = getPackageMetadata();
2225
3249
  const program = new Command();
2226
- program.name("hmd").description(description || "Heimdall: resilient validator and updater for system and global packages").version(version).option("-y, --yes", "Skip confirmation prompt and update all automatically").option("-d, --dry-run", "Inspect outdated packages and simulate update without running changes").option("-o, --only <managers>", "Only check and update specified managers (comma-separated, e.g. brew,npm)").option("-x, --exclude <managers>", "Exclude specified managers (comma-separated, e.g. macports,pip)").option("-t, --timeout <ms>", "Timeout per check in milliseconds", "25000").option("-v, --verbose", "Show detailed output and logs").parse(process.argv);
3250
+ program.name("hmd").description(description || "Heimdall: resilient validator and updater for system and global packages").version(version).option("-y, --yes", "Skip confirmation prompt and update all automatically").option("-d, --dry-run", "Inspect outdated packages and simulate update without running changes").option("-o, --only <managers>", "Only check and update specified managers (comma-separated, e.g. brew,npm)").option("-x, --exclude <managers>", "Exclude specified managers (comma-separated, e.g. macports,pip)").option("-t, --timeout <ms>", "Timeout per check in milliseconds", "25000").option("-v, --verbose", "Show detailed output and logs").option("--no-distro-upgrade", "Skip the distribution release upgrade check at the end").option(
3251
+ "-U, --upgrade-distro",
3252
+ "Apply a detected distribution release upgrade without asking (never reboots)"
3253
+ ).parse(process.argv);
2227
3254
  const options = program.opts();
2228
3255
  if (process.stdout.isTTY && !options.verbose) {
2229
3256
  console.clear();
@@ -2269,8 +3296,9 @@ async function main() {
2269
3296
  pc2.green("All detected package managers and global packages are completely up to date! \u{1F389}"),
2270
3297
  "Status: Up to Date"
2271
3298
  );
2272
- p.outro(pc2.cyan("No updates needed. Have a great day!"));
2273
- process.exit(0);
3299
+ const distroOk2 = await offerDistroUpgrade(options, timeoutMs, options.dryRun ?? false);
3300
+ p.outro(pc2.cyan("No package updates needed. Have a great day!"));
3301
+ process.exit(distroOk2 ? 0 : 1);
2274
3302
  }
2275
3303
  p.log.message(`
2276
3304
  ${pc2.bold("Pending updates")} ${pc2.dim(`(${allUpdates.length} total):`)}`);
@@ -2341,6 +3369,21 @@ ${pc2.bold("Pending updates")} ${pc2.dim(`(${allUpdates.length} total):`)}`);
2341
3369
  process.exit(0);
2342
3370
  }
2343
3371
  const isSimulated = options.dryRun ?? false;
3372
+ let stopSudoKeepAlive;
3373
+ const selectedIds = new Set(managersToUpdate);
3374
+ const needsSudo = selectedManagers.some((m) => selectedIds.has(m.id) && m.requiresSudo);
3375
+ if (needsSudo && !isSimulated) {
3376
+ p.log.step(pc2.dim("Requesting sudo privileges required by system package managers..."));
3377
+ if (await ensureSudo()) {
3378
+ stopSudoKeepAlive = startSudoKeepAlive();
3379
+ } else {
3380
+ p.log.warn(
3381
+ pc2.yellow(
3382
+ 'Sudo privileges not granted: system package managers will fail. Run "sudo -v" in this terminal and retry.'
3383
+ )
3384
+ );
3385
+ }
3386
+ }
2344
3387
  p.log.step(
2345
3388
  pc2.bold(
2346
3389
  pc2.cyan(
@@ -2351,52 +3394,58 @@ ${isSimulated ? "[DRY RUN] Simulating" : "Starting"} updates for ${managersToUpd
2351
3394
  );
2352
3395
  const updateSpinner = p.spinner();
2353
3396
  let managerIndex = 0;
2354
- const summary = await engine.execute(
2355
- managersToUpdate,
2356
- itemsByManager,
2357
- {
2358
- verbose: options.verbose,
2359
- dryRun: isSimulated,
2360
- confirmAction: async (message) => {
2361
- if (options.yes) return true;
2362
- updateSpinner.stop(pc2.dim("Waiting for user confirmation..."));
2363
- const confirmed = await p.confirm({ message, initialValue: false });
2364
- updateSpinner.start(pc2.dim("Continuing update..."));
2365
- return typeof confirmed === "boolean" ? confirmed : false;
2366
- },
2367
- onStepStart: (stepName) => {
2368
- updateSpinner.message(pc2.dim(`Running: ${stepName}...`));
2369
- },
2370
- onStepProgress: (stepName, logLine) => {
2371
- if (logLine) {
2372
- const truncated = logLine.length > 55 ? logLine.slice(0, 52) + "..." : logLine;
2373
- updateSpinner.message(pc2.dim(`${stepName} [${truncated}]`));
3397
+ let summary;
3398
+ try {
3399
+ summary = await engine.execute(
3400
+ managersToUpdate,
3401
+ itemsByManager,
3402
+ {
3403
+ verbose: options.verbose,
3404
+ dryRun: isSimulated,
3405
+ confirmAction: async (message) => {
3406
+ if (options.yes) return true;
3407
+ updateSpinner.stop(pc2.dim("Waiting for user confirmation..."));
3408
+ const confirmed = await p.confirm({ message, initialValue: false });
3409
+ updateSpinner.start(pc2.dim("Continuing update..."));
3410
+ return typeof confirmed === "boolean" ? confirmed : false;
3411
+ },
3412
+ onStepStart: (stepName) => {
3413
+ updateSpinner.message(pc2.dim(`Running: ${stepName}...`));
3414
+ },
3415
+ onStepProgress: (stepName, logLine) => {
3416
+ if (logLine) {
3417
+ const truncated = logLine.length > 55 ? logLine.slice(0, 52) + "..." : logLine;
3418
+ updateSpinner.message(pc2.dim(`${stepName} [${truncated}]`));
3419
+ }
3420
+ },
3421
+ onStepEnd: (stepName, success, error, durationMs) => {
3422
+ const mark = success ? pc2.green("\u2714") : pc2.red("\u2716");
3423
+ const durStr = durationMs !== void 0 ? pc2.dim(` [${formatDuration(durationMs)}]`) : "";
3424
+ const detail = error ? pc2.red(` \u21B3 ${error.split("\n")[0]}`) : "";
3425
+ updateSpinner.stop(` ${mark} ${pc2.dim(stepName)}${durStr}${detail}`);
3426
+ updateSpinner.start(pc2.dim("Working..."));
2374
3427
  }
2375
3428
  },
2376
- onStepEnd: (stepName, success, error, durationMs) => {
2377
- const mark = success ? pc2.green("\u2714") : pc2.red("\u2716");
2378
- const durStr = durationMs !== void 0 ? pc2.dim(` [${formatDuration(durationMs)}]`) : "";
2379
- const detail = error ? pc2.red(` \u21B3 ${error.split("\n")[0]}`) : "";
2380
- updateSpinner.stop(` ${mark} ${pc2.dim(stepName)}${durStr}${detail}`);
2381
- updateSpinner.start(pc2.dim("Working..."));
2382
- }
2383
- },
2384
- (mgr) => {
2385
- managerIndex++;
2386
- console.log(pc2.bold(pc2.cyan(`
3429
+ (mgr) => {
3430
+ managerIndex++;
3431
+ console.log(pc2.bold(pc2.cyan(`
2387
3432
  \u25CF [${managerIndex}/${managersToUpdate.length}] ${mgr.icon} ${mgr.name}`)));
2388
- updateSpinner.start(pc2.dim("Starting update routine..."));
2389
- },
2390
- (mgr, res) => {
2391
- if (res.success) {
2392
- updateSpinner.stop(pc2.green(` \u2714 ${mgr.name} completed in ${formatDuration(res.durationMs)}`));
2393
- } else {
2394
- updateSpinner.stop(pc2.red(` \u2716 ${mgr.name} completed with issues in ${formatDuration(res.durationMs)}`));
3433
+ updateSpinner.start(pc2.dim("Starting update routine..."));
3434
+ },
3435
+ (mgr, res) => {
3436
+ if (res.success) {
3437
+ updateSpinner.stop(pc2.green(` \u2714 ${mgr.name} completed in ${formatDuration(res.durationMs)}`));
3438
+ } else {
3439
+ updateSpinner.stop(pc2.red(` \u2716 ${mgr.name} completed with issues in ${formatDuration(res.durationMs)}`));
3440
+ }
2395
3441
  }
2396
- }
2397
- );
3442
+ );
3443
+ } finally {
3444
+ stopSudoKeepAlive?.();
3445
+ }
2398
3446
  console.log(renderExecutionSummary(summary.results));
2399
- if (summary.failedCount === 0) {
3447
+ const distroOk = await offerDistroUpgrade(options, timeoutMs, isSimulated);
3448
+ if (summary.failedCount === 0 && distroOk) {
2400
3449
  p.outro(
2401
3450
  pc2.bold(
2402
3451
  pc2.green(
@@ -2406,10 +3455,11 @@ ${isSimulated ? "[DRY RUN] Simulating" : "Starting"} updates for ${managersToUpd
2406
3455
  );
2407
3456
  } else {
2408
3457
  process.exitCode = 1;
3458
+ const distroNote = distroOk ? "" : ", distribution upgrade failed";
2409
3459
  p.outro(
2410
3460
  pc2.bold(
2411
3461
  pc2.yellow(
2412
- `Completed with warnings: ${summary.successCount} succeeded, ${summary.failedCount} failed (${formatDuration(summary.totalDurationMs)}).`
3462
+ `Completed with warnings: ${summary.successCount} succeeded, ${summary.failedCount} failed${distroNote} (${formatDuration(summary.totalDurationMs)}).`
2413
3463
  )
2414
3464
  )
2415
3465
  );