feat(doctor): report available package updates

HostProvisioner gains availableUpdates() — package names with an update
available (null when the backend can't report it; empty = up to date).
PackageKit implements it via GetUpdates (the same data pkcon get-updates
uses); brew/winget return null for now.

emb doctor, after confirming the backend is available, prints the update
count (+ a short preview), labeled as reflecting the last cache refresh.
It stays read-only (no refresh) and never fails the command on the update
check.

Adds a doctor_command test (the feature had none): wiring, up-to-date,
unsupported, error-tolerance, and unavailable-skips-the-check. README +
example/README document the new line.

Signed-off-by: Joel Winarske <joel.winarske@gmail.com>
This commit is contained in:
Joel Winarske
2026-06-19 10:17:53 -07:00
parent b3833cd2fe
commit 8f12358a34
10 changed files with 163 additions and 5 deletions
+5 -2
View File
@@ -176,8 +176,11 @@ each entry.
### `emb doctor`
Report host detection (os / arch / distro) and package-manager backend
availability. No options. Exit code is non-zero if the backend is unavailable.
Report host detection (os / arch / distro), package-manager backend
availability, and — when the backend can report it (PackageKit) — the count of
available package updates (from the backend's last cache refresh; `doctor` is
read-only and doesn't refresh). No options. Exit code is non-zero if the backend
is unavailable; the update check never fails the command.
```sh
emb doctor
+2
View File
@@ -26,6 +26,8 @@ Host
Package backend
selected: packagekit
✓ packagekit is available
✓ 3 updates available
bash, curl, mesa-libEGL, …
```
## 2. Provision a workspace
+23 -3
View File
@@ -54,12 +54,32 @@ class DoctorCommand extends Command<int> {
final progress = _logger.progress('Checking ${provisioner.name}');
try {
final available = await provisioner.isAvailable();
if (available) {
progress.complete('${provisioner.name} is available');
} else {
if (!available) {
progress.fail('${provisioner.name} is not available');
return ExitCode.unavailable.code;
}
progress.complete('${provisioner.name} is available');
// Available updates — best-effort and read-only (reflects the backend's
// last cache refresh; never fails the command).
final updateCheck = _logger.progress('Checking for available updates');
try {
final updates = await provisioner.availableUpdates();
if (updates == null) {
updateCheck.complete(
'update check not supported by ${provisioner.name}',
);
} else if (updates.isEmpty) {
updateCheck.complete('up to date');
} else {
final n = updates.length;
updateCheck.complete('$n update${n == 1 ? "" : "s"} available');
final preview = updates.take(6).join(', ');
_logger.info(' $preview${n > 6 ? ", …" : ""}');
}
} on Object {
updateCheck.fail('update check failed');
}
} finally {
await provisioner.dispose();
}
@@ -19,6 +19,9 @@ class BrewProvisioner implements HostProvisioner {
@override
Future<bool> isAvailable() => _brew.isInstalled();
@override
Future<List<String>?> availableUpdates() async => null; // not reported here
Future<Set<String>> _installedNames() async =>
_installedCache ??= (await _brew.listNames()).toSet();
@@ -34,6 +34,9 @@ class WingetProvisioner implements HostProvisioner {
}
}
@override
Future<List<String>?> availableUpdates() async => null; // not reported here
@override
Future<Set<String>> missing(Set<String> names) async {
if (names.isEmpty) return {};
+5
View File
@@ -40,6 +40,11 @@ abstract class HostProvisioner {
/// Whether the backend daemon/CLI is reachable on this host.
Future<bool> isAvailable();
/// Package names with an available update, per the backend's last cache
/// refresh; null when the backend can't report it (return null rather than
/// guessing), and an empty list means "up to date".
Future<List<String>?> availableUpdates();
/// Return the subset of [names] that is NOT currently installed. This is the
/// "filter" primitive used by the coalesce/filter stage.
Future<Set<String>> missing(Set<String> names);
+10
View File
@@ -38,6 +38,16 @@ class PackageKitProvisioner implements HostProvisioner {
}
}
@override
Future<List<String>?> availableUpdates() async {
final client = await _connect();
// `GetUpdates` reflects the last cache refresh (we don't refresh here, to
// keep doctor read-only/fast). Distinct names, sorted for stable output.
final pkgs = await _collectPackages(client.getUpdates());
final names = {for (final p in pkgs) p.id.name}.toList()..sort();
return names;
}
@override
Future<Set<String>> missing(Set<String> names) async {
if (names.isEmpty) return {};
+106
View File
@@ -0,0 +1,106 @@
import 'package:args/command_runner.dart';
import 'package:emb_cli/src/commands/doctor_command.dart';
import 'package:emb_cli/src/host/host_info.dart';
import 'package:emb_cli/src/pkg/host_provisioner.dart';
import 'package:emb_cli/src/pkg/provision_models.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:test/test.dart';
const _host = HostInfo(
os: HostOs.linux,
machineArch: 'x86_64',
archAliases: {'x86_64', 'x64', 'amd64'},
hostType: 'fedora',
versionId: '43',
);
class _FakeProvisioner implements HostProvisioner {
_FakeProvisioner({
this.available = true,
this.updates,
this.throwOnUpdates = false,
});
final bool available;
final List<String>? updates;
final bool throwOnUpdates;
bool updatesChecked = false;
@override
String get name => 'fake';
@override
Future<bool> isAvailable() async => available;
@override
Future<List<String>?> availableUpdates() async {
updatesChecked = true;
if (throwOnUpdates) throw StateError('boom');
return updates;
}
@override
Future<Set<String>> missing(Set<String> names) async => {};
@override
Future<ProvisionPlan> simulate(Set<String> names) async =>
ProvisionPlan.empty;
@override
Future<ProvisionResult> install(
Set<String> names, {
void Function(ProvisionProgress progress)? onProgress,
}) async => ProvisionResult(installed: names.toList());
@override
Future<void> dispose() async {}
}
Future<int?> _run(_FakeProvisioner p) {
final runner = CommandRunner<int>('emb', 'test')
..addCommand(
DoctorCommand(
logger: Logger(),
host: _host,
provisionerFactory: (_) => p,
),
);
return runner.run(['doctor']);
}
void main() {
test('checks for updates when the backend is available', () async {
final p = _FakeProvisioner(updates: ['a', 'b', 'c']);
expect(await _run(p), ExitCode.success.code);
expect(p.updatesChecked, isTrue);
});
test('an empty update list (up to date) succeeds', () async {
final p = _FakeProvisioner(updates: const []);
expect(await _run(p), ExitCode.success.code);
expect(p.updatesChecked, isTrue);
});
test("a backend that can't report updates (null) succeeds", () async {
final p = _FakeProvisioner();
expect(await _run(p), ExitCode.success.code);
expect(p.updatesChecked, isTrue);
});
test('an update-check error never fails doctor', () async {
final p = _FakeProvisioner(throwOnUpdates: true);
expect(await _run(p), ExitCode.success.code);
expect(p.updatesChecked, isTrue);
});
test(
'an unavailable backend exits unavailable and skips the update check',
() {
final p = _FakeProvisioner(available: false);
return _run(p).then((code) {
expect(code, ExitCode.unavailable.code);
expect(p.updatesChecked, isFalse);
});
},
);
}
@@ -33,6 +33,9 @@ class _FakeProvisioner implements HostProvisioner {
return true;
}
@override
Future<List<String>?> availableUpdates() async => null;
@override
Future<Set<String>> missing(Set<String> names) async => {};
@override
@@ -16,6 +16,9 @@ class _FakeProvisioner implements HostProvisioner {
@override
Future<bool> isAvailable() async => true;
@override
Future<List<String>?> availableUpdates() async => null;
@override
Future<Set<String>> missing(Set<String> names) async =>
names.difference(installed);