mirror of
https://github.com/toyota-connected/emb_cli.git
synced 2026-06-21 07:19:37 -07:00
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:
@@ -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
|
||||
|
||||
@@ -26,6 +26,8 @@ Host
|
||||
Package backend
|
||||
selected: packagekit
|
||||
✓ packagekit is available
|
||||
✓ 3 updates available
|
||||
bash, curl, mesa-libEGL, …
|
||||
```
|
||||
|
||||
## 2. Provision a workspace
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user