diff --git a/packages/vgv_ai_cli/lib/src/command_runner.dart b/packages/vgv_ai_cli/lib/src/command_runner.dart index a642f86..6e7ae28 100644 --- a/packages/vgv_ai_cli/lib/src/command_runner.dart +++ b/packages/vgv_ai_cli/lib/src/command_runner.dart @@ -31,15 +31,17 @@ class VgvAiCliCommandRunner({ InstallCommand? installCommand, DoctorCommand? doctorCommand, UninstallCommand? uninstallCommand, + UpdateCommand? updateCommand, }) extends CompletionCommandRunner { /// {@macro vgv_ai_cli_command_runner} /// /// [installation] defaults to [Installation.current], and decides how /// `update` and the update prompt tell the user to upgrade. /// - /// [installCommand], [doctorCommand] and [uninstallCommand] replace the - /// default `install`, `doctor` and `uninstall`, whose collaborators - /// otherwise reach the real home directory and plugin cache. + /// [installCommand], [doctorCommand], [uninstallCommand] and + /// [updateCommand] replace the default `install`, `doctor`, `uninstall` and + /// `update`, whose collaborators otherwise reach the real home directory, + /// plugin cache and processes. this : super(executableName, description) { // Add root options and flags argParser @@ -59,11 +61,12 @@ class VgvAiCliCommandRunner({ addCommand(installCommand ?? InstallCommand(logger: _logger)); addCommand(uninstallCommand ?? UninstallCommand(logger: _logger)); addCommand( - UpdateCommand( - logger: _logger, - pubUpdater: _pubUpdater, - installation: _installation, - ), + updateCommand ?? + UpdateCommand( + logger: _logger, + pubUpdater: _pubUpdater, + installation: _installation, + ), ); } diff --git a/packages/vgv_ai_cli/lib/src/commands/install_command.dart b/packages/vgv_ai_cli/lib/src/commands/install_command.dart index dd06186..5204e26 100644 --- a/packages/vgv_ai_cli/lib/src/commands/install_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/install_command.dart @@ -27,6 +27,8 @@ import 'package:vgv_ai_plugins/vgv_ai_plugins.dart'; /// * `--plugin` installs just the named VGV plugins. Without it, a run in a /// terminal asks which plugins to install, and any other run installs /// every pinned one. +/// * `--refresh` reinstalls the harnesses and plugins the manifest records, +/// at the pinned versions, without asking. `update` runs it. /// {@endtemplate} class InstallCommand({ required final Logger _logger, @@ -57,6 +59,14 @@ class InstallCommand({ 'plugin', help: 'Install just these VGV plugins instead of choosing them.', allowed: [for (final plugin in vgvPlugins) plugin.name], + ) + ..addFlag( + refreshFlag, + help: + 'Reinstall the harnesses and plugins a previous install wrote, ' + 'at the pinned versions.', + negatable: false, + hide: true, ); addHarnessOptions('Configure the Very Good AI tooling for'); } @@ -65,6 +75,10 @@ class InstallCommand({ /// without constructing one. static const String commandName = 'install'; + /// The flag `update` passes to reinstall what a previous install wrote. + /// Older versions of `update` pass it too, so it must keep its name. + static const String refreshFlag = 'refresh'; + /// Whether stdin is a terminal, so install can ask which plugins to install. final bool _hasTerminal = hasTerminal ?? stdin.hasTerminal; @@ -95,6 +109,8 @@ class InstallCommand({ Future run() async { final force = argResults!['force'] == true; final dryRun = argResults!['check'] == true; + final refresh = argResults![refreshFlag] == true; + if (refresh) _rejectSelectionWithRefresh(); // Falling back to the working directory would scatter harness config // wherever install happened to run, so a missing home is fatal. @@ -106,16 +122,6 @@ class InstallCommand({ return ExitCode.config.code; } - final selected = await _select(); - if (selected.isEmpty) { - _logger.err( - 'No harness selected. Install one of ' - '${Harness.values.map((harness) => harness.displayName).join(', ')}, ' - 'or pass --only to name one explicitly.', - ); - return ExitCode.usage.code; - } - // Loaded before anything is resolved, since a manifest this version // cannot read means nothing can be installed. final store = ReportingManifestStore( @@ -128,7 +134,15 @@ class InstallCommand({ final manifest = store.load(); if (manifest == null) return ExitCode.ioError.code; - final plugins = _selectPlugins(manifest, selected); + final selected = refresh ? manifest.plugins.keys.toSet() : await _select(); + if (selected.isEmpty) { + _logger.err(refresh ? _nothingToRefresh : _noHarnessSelected); + return ExitCode.usage.code; + } + + final plugins = refresh + ? _recordedPlugins(manifest, selected) + : _selectPlugins(manifest, selected); if (plugins.isEmpty) { _logger.err( 'No plugin selected. Choose at least one, or pass --plugin to name ' @@ -171,6 +185,53 @@ class InstallCommand({ : ExitCode.success.code; } + /// Why there is nothing to install when no harness is selected. + static final String _noHarnessSelected = + 'No harness selected. Install one of ' + '${Harness.values.map((harness) => harness.displayName).join(', ')}, ' + 'or pass --only to name one explicitly.'; + + /// Why `--refresh` installs nothing when the manifest records no plugins. + static const String _nothingToRefresh = + 'Nothing to refresh: no previous install recorded its plugins. Run ' + 'install to choose them.'; + + /// Throws a [UsageException] when `--refresh` is combined with an option + /// that picks harnesses or plugins, since it takes both from the manifest. + void _rejectSelectionWithRefresh() { + final picked = [ + 'only', + 'plugin', + ...Harness.values.map((harness) => harness.name), + ].where(argResults!.wasParsed); + if (picked.isNotEmpty) { + usageException( + '--$refreshFlag cannot be combined with ' + '${picked.map((option) => '--$option').join(', ')}.', + ); + } + } + + /// The plugins [manifest] records for [selected] that this version still + /// pins, warning about the ones it no longer does. + Set _recordedPlugins( + InstallManifest manifest, + Set selected, + ) { + final recorded = { + for (final harness in selected) ...?manifest.plugins[harness]?.keys, + }; + final pinned = {for (final plugin in vgvPlugins) plugin.name}; + final dropped = recorded.difference(pinned); + if (dropped.isNotEmpty) { + _logger.warn( + 'Skipping ${dropped.join(', ')}, which this version no longer ' + 'installs.', + ); + } + return recorded.intersection(pinned); + } + /// Resolves which harnesses to configure. /// /// `--only` wins outright and bypasses detection. Otherwise every harness diff --git a/packages/vgv_ai_cli/lib/src/commands/update_command.dart b/packages/vgv_ai_cli/lib/src/commands/update_command.dart index ebecf02..a3b6e3b 100644 --- a/packages/vgv_ai_cli/lib/src/commands/update_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/update_command.dart @@ -1,14 +1,20 @@ import 'dart:io'; import 'package:args/command_runner.dart'; +import 'package:file/file.dart' show Directory; import 'package:mason_logger/mason_logger.dart'; import 'package:pub_updater/pub_updater.dart'; import 'package:vgv_ai_cli/src/command_runner.dart'; +import 'package:vgv_ai_cli/src/commands/install_command.dart'; import 'package:vgv_ai_cli/src/commands/installation.dart'; +import 'package:vgv_ai_cli/src/commands/user_home.dart'; +import 'package:vgv_ai_cli/src/manifest/manifest.dart'; +import 'package:vgv_ai_cli/src/model/model.dart'; import 'package:vgv_ai_cli/src/version.dart'; /// {@template update_command} -/// Updates `vgv_ai` to the latest version on pub.dev. +/// Updates `vgv_ai` to the latest version on pub.dev, then reinstalls the +/// harnesses and plugins a previous `install` wrote, at the new pins. /// /// An installation other than [Installation.pub] is not updated: the command /// prints its [Installation.upgradeCommand] instead. @@ -17,6 +23,9 @@ class UpdateCommand({ required final Logger _logger, PubUpdater? pubUpdater, Installation? installation, + final Directory? _home, + final Map? _environment, + final StartProcess _startProcess = Process.start, }) extends Command { /// {@macro update_command} /// @@ -36,6 +45,11 @@ class UpdateCommand({ @override String get name => commandName; + /// What the user runs to move installed plugins to the new pins. + static final String reinstallHint = + 'Then run ${lightCyan.wrap('$executableName install')} ' + 'to update your installed plugins.'; + @override Future run() async { final updateCheckProgress = _logger.progress('Checking for updates'); @@ -56,13 +70,23 @@ class UpdateCommand({ } if (_installation != Installation.pub) { - _logger.info( - '$executableName $latestVersion is available. ' - 'Run ${_installation.upgradeCommand} to update.', - ); + _logger + ..info( + '$executableName $latestVersion is available. ' + 'Run ${_installation.upgradeCommand} to update.', + ) + ..info(reinstallHint); return ExitCode.success.code; } + final updated = await _update(latestVersion); + if (!updated) return ExitCode.software.code; + + return _wasInstalled ? await _reinstall() : ExitCode.success.code; + } + + /// Updates the CLI to [latestVersion], and returns whether it could. + Future _update(String latestVersion) async { final updateProgress = _logger.progress('Updating to $latestVersion'); late final ProcessResult result; @@ -74,17 +98,45 @@ class UpdateCommand({ } on Exception catch (error) { updateProgress.fail(); _logger.err('$error'); - return ExitCode.software.code; + return false; } if (result.exitCode != ExitCode.success.code) { updateProgress.fail(); _logger.err('Error updating CLI: ${result.stderr}'); - return ExitCode.software.code; + return false; } updateProgress.complete('Updated to $latestVersion'); + return true; + } + + /// Whether a previous `install` left a manifest behind. + bool get _wasInstalled { + final home = commandHome(_home, _environment); + return home != null && ManifestStore(home).file.existsSync(); + } - return ExitCode.success.code; + /// Runs `install --refresh` from the version just activated, which + /// carries the new pins, for the harnesses and plugins installed before. + Future _reinstall() async { + _logger.info('Updating your installed plugins...'); + try { + final process = await _startProcess(Platform.resolvedExecutable, [ + 'pub', + 'global', + 'run', + '$packageName:$executableName', + if (globalResults?['verbose'] == true) '--verbose', + InstallCommand.commandName, + '--${InstallCommand.refreshFlag}', + ], mode: ProcessStartMode.inheritStdio); + return await process.exitCode; + } on ProcessException catch (error) { + _logger + ..err('Could not update your installed plugins: ${error.message}') + ..info(reinstallHint); + return ExitCode.software.code; + } } } diff --git a/packages/vgv_ai_cli/lib/src/model/run_process.dart b/packages/vgv_ai_cli/lib/src/model/run_process.dart index de9ee4d..a5fb200 100644 --- a/packages/vgv_ai_cli/lib/src/model/run_process.dart +++ b/packages/vgv_ai_cli/lib/src/model/run_process.dart @@ -6,3 +6,10 @@ typedef RunProcess = Future Function( List arguments, { bool runInShell, }); + +/// Signature of [Process.start], narrowed to what the CLI needs. +typedef StartProcess = Future Function( + String executable, + List arguments, { + ProcessStartMode mode, +}); diff --git a/packages/vgv_ai_cli/test/helpers/test_runner.dart b/packages/vgv_ai_cli/test/helpers/test_runner.dart index ff909bd..6739b98 100644 --- a/packages/vgv_ai_cli/test/helpers/test_runner.dart +++ b/packages/vgv_ai_cli/test/helpers/test_runner.dart @@ -58,6 +58,7 @@ class TestRunner() { InstallCommand? installCommand, DoctorCommand? doctorCommand, UninstallCommand? uninstallCommand, + UpdateCommand? updateCommand, }) => VgvAiCliCommandRunner( logger: logger, pubUpdater: pubUpdater, @@ -65,6 +66,7 @@ class TestRunner() { installCommand: installCommand, doctorCommand: doctorCommand, uninstallCommand: uninstallCommand, + updateCommand: updateCommand, ); /// Runs [args] through a runner from [build]. @@ -74,10 +76,12 @@ class TestRunner() { InstallCommand? installCommand, DoctorCommand? doctorCommand, UninstallCommand? uninstallCommand, + UpdateCommand? updateCommand, }) => build( installation: installation, installCommand: installCommand, doctorCommand: doctorCommand, uninstallCommand: uninstallCommand, + updateCommand: updateCommand, ).run(args); } diff --git a/packages/vgv_ai_cli/test/src/command_runner_test.dart b/packages/vgv_ai_cli/test/src/command_runner_test.dart index 7a55454..e6b7c7f 100644 --- a/packages/vgv_ai_cli/test/src/command_runner_test.dart +++ b/packages/vgv_ai_cli/test/src/command_runner_test.dart @@ -117,7 +117,16 @@ Run ${lightCyan.wrap('$executableName update')} to update'''; ), ).thenAnswer((_) async => true); - final result = await commandRunner.run(['update']); + // No home directory, so update never reinstalls into the real one. + final result = await runner.run( + ['update'], + updateCommand: UpdateCommand( + logger: logger, + pubUpdater: pubUpdater, + installation: Installation.pub, + environment: const {}, + ), + ); expect(result, ExitCode.success.code); verifyNever(() => logger.info(updatePrompt)); }); diff --git a/packages/vgv_ai_cli/test/src/commands/install_command_test.dart b/packages/vgv_ai_cli/test/src/commands/install_command_test.dart index 4e1c51f..4022104 100644 --- a/packages/vgv_ai_cli/test/src/commands/install_command_test.dart +++ b/packages/vgv_ai_cli/test/src/commands/install_command_test.dart @@ -443,6 +443,92 @@ void main() { }); }); + group('--refresh', () { + setUp(() { + translator.plan = TranslationPlan( + harnesses: { + Harness.claude: TranslationResult( + changes: [ownChange(Harness.claude, '.claude/a.md', 'a')], + ), + }, + ); + }); + + /// Installs once, then records [plugins] as what that install wrote. + Future recordInstall( + Map> plugins, + ) async { + await runInstall(['--only', 'claude']); + final store = ManifestStore(home); + store.save(store.load()!.withPlugins(plugins)); + } + + test('reinstalls the recorded harnesses and plugins', () async { + final kept = vgvPlugins.first; + await recordInstall({ + Harness.claude: {kept.name: '0.0.1'}, + Harness.gemini: {kept.name: '0.0.1'}, + }); + + final exitCode = await runInstall(['--refresh'], hasTerminal: true); + + expect(exitCode, ExitCode.success.code); + expect(translator.selected, {Harness.claude, Harness.gemini}); + expect(translator.assets?.plugins, [kept]); + verifyNever(pickPlugins); + verifyNever(() => detector.detect(any())); + }); + + test('skips a recorded plugin this version no longer pins', () async { + final kept = vgvPlugins.first; + await recordInstall({ + Harness.claude: {kept.name: '0.0.1', 'retired-plugin': '0.0.1'}, + }); + + final exitCode = await runInstall(['--refresh']); + + expect(exitCode, ExitCode.success.code); + expect(translator.assets?.plugins, [kept]); + verify( + () => logger.warn( + 'Skipping retired-plugin, which this version no longer installs.', + ), + ).called(1); + }); + + test('installs nothing without a recorded install', () async { + final exitCode = await runInstall(['--refresh']); + + expect(exitCode, ExitCode.usage.code); + expect(translator.selected, isNull); + verify( + () => logger.err( + 'Nothing to refresh: no previous install recorded its plugins. ' + 'Run install to choose them.', + ), + ).called(1); + }); + + test('cannot be combined with a selection', () async { + final exitCode = await runInstall([ + '--refresh', + '--only', + 'claude', + '--plugin', + vgvPlugins.first.name, + '--no-codex', + ]); + + expect(exitCode, ExitCode.usage.code); + expect(translator.selected, isNull); + verify( + () => logger.err( + '--refresh cannot be combined with --only, --plugin, --codex.', + ), + ).called(1); + }); + }); + group('resolution', () { test('clones the plugins when there is no Claude Code cache', () async { final cloned = >[]; diff --git a/packages/vgv_ai_cli/test/src/commands/update_command_test.dart b/packages/vgv_ai_cli/test/src/commands/update_command_test.dart index 4df35f9..eba12e0 100644 --- a/packages/vgv_ai_cli/test/src/commands/update_command_test.dart +++ b/packages/vgv_ai_cli/test/src/commands/update_command_test.dart @@ -1,29 +1,75 @@ import 'dart:io'; +import 'package:file/file.dart' show Directory; +import 'package:file/memory.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pub_updater/pub_updater.dart'; import 'package:test/test.dart'; import 'package:vgv_ai_cli/src/command_runner.dart'; import 'package:vgv_ai_cli/src/commands/commands.dart'; +import 'package:vgv_ai_cli/src/manifest/manifest.dart'; import 'package:vgv_ai_cli/src/version.dart'; import '../../helpers/test_runner.dart'; +class _MockProcess extends Mock implements Process; + +/// One call to the injected `startProcess`. +class _Start( + final String executable, + final List arguments, + final ProcessStartMode mode, +); + void main() { group(UpdateCommand, () { const latestVersion = '0.0.0'; + const runNewVersion = ['pub', 'global', 'run', 'vgv_ai_cli:vgv_ai']; late PubUpdater pubUpdater; late Logger logger; late TestRunner runner; + late Directory home; + late Process process; + late List<_Start> started; late VgvAiCliCommandRunner commandRunner; + Future startProcess( + String executable, + List arguments, { + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + started.add(_Start(executable, arguments, mode)); + return process; + } + + UpdateCommand updateCommand({ + Directory? home, + Map? environment, + }) => UpdateCommand( + logger: logger, + pubUpdater: pubUpdater, + installation: Installation.pub, + home: home, + environment: environment, + startProcess: startProcess, + ); + + void recordInstall() => ManifestStore(home).file + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + setUp(() { runner = TestRunner(); pubUpdater = runner.pubUpdater; logger = runner.logger; - commandRunner = runner.build(); + home = MemoryFileSystem.test().directory('/home/dev') + ..createSync(recursive: true); + process = _MockProcess(); + when(() => process.exitCode).thenAnswer((_) async => 0); + started = []; + commandRunner = runner.build(updateCommand: updateCommand(home: home)); when( () => pubUpdater.update( @@ -56,6 +102,7 @@ void main() { }); test('handles pub update errors', () async { + recordInstall(); when(() => pubUpdater.getLatestVersion(any())) .thenAnswer((_) async => latestVersion); when( @@ -74,10 +121,12 @@ void main() { versionConstraint: any(named: 'versionConstraint'), ), ).called(1); + expect(started, isEmpty); }); test('handles pub update process errors', () async { const error = 'Oh no! Installing this is not possible right now!'; + recordInstall(); when(() => pubUpdater.getLatestVersion(any())) .thenAnswer((_) async => latestVersion); @@ -100,29 +149,93 @@ void main() { versionConstraint: any(named: 'versionConstraint'), ), ).called(1); + expect(started, isEmpty); }); - test('updates when newer version exists', () async { - when(() => pubUpdater.getLatestVersion(any())) - .thenAnswer((_) async => latestVersion); - when( - () => pubUpdater.update( - packageName: any(named: 'packageName'), - versionConstraint: any(named: 'versionConstraint'), - ), - ).thenAnswer( - (_) async => ProcessResult(0, ExitCode.success.code, null, null), - ); - final result = await commandRunner.run(['update']); - expect(result, ExitCode.success.code); - verify(() => logger.progress('Checking for updates')).called(1); - verify(() => logger.progress('Updating to $latestVersion')).called(1); - verify( - () => pubUpdater.update( - packageName: packageName, - versionConstraint: latestVersion, - ), - ).called(1); + group('when newer version exists', () { + setUp(() { + when(() => pubUpdater.getLatestVersion(any())) + .thenAnswer((_) async => latestVersion); + }); + + test('updates', () async { + final result = await commandRunner.run(['update']); + expect(result, ExitCode.success.code); + verify(() => logger.progress('Checking for updates')).called(1); + verify(() => logger.progress('Updating to $latestVersion')).called(1); + verify( + () => pubUpdater.update( + packageName: packageName, + versionConstraint: latestVersion, + ), + ).called(1); + }); + + test('reinstalls with the updated version after an install', () async { + recordInstall(); + final result = await commandRunner.run(['update']); + expect(result, ExitCode.success.code); + verify(() => logger.info('Updating your installed plugins...')) + .called(1); + final start = started.single; + expect(start.executable, Platform.resolvedExecutable); + expect(start.arguments, [...runNewVersion, 'install', '--refresh']); + expect(start.mode, ProcessStartMode.inheritStdio); + }); + + test('reinstalls verbosely when updating verbosely', () async { + recordInstall(); + await commandRunner.run(['--verbose', 'update']); + expect(started.single.arguments, [ + ...runNewVersion, + '--verbose', + 'install', + '--refresh', + ]); + }); + + test('returns the exit code of the reinstall', () async { + recordInstall(); + when(() => process.exitCode) + .thenAnswer((_) async => ExitCode.usage.code); + final result = await commandRunner.run(['update']); + expect(result, ExitCode.usage.code); + }); + + test('reports a reinstall that cannot start', () async { + recordInstall(); + final result = await runner.run( + ['update'], + updateCommand: UpdateCommand( + logger: logger, + pubUpdater: pubUpdater, + installation: Installation.pub, + home: home, + startProcess: (executable, arguments, {mode = .normal}) => + throw const ProcessException('dart', [], 'not found'), + ), + ); + expect(result, ExitCode.software.code); + verify( + () => + logger.err('Could not update your installed plugins: not found'), + ).called(1); + verify(() => logger.info(UpdateCommand.reinstallHint)).called(1); + }); + + test('does not reinstall without an install', () async { + final result = await commandRunner.run(['update']); + expect(result, ExitCode.success.code); + expect(started, isEmpty); + }); + + test('does not reinstall without a home directory', () async { + final result = await runner.run([ + 'update', + ], updateCommand: updateCommand(environment: const {})); + expect(result, ExitCode.success.code); + expect(started, isEmpty); + }); }); for (final installation in [Installation.homebrew, Installation.script]) { @@ -140,6 +253,7 @@ void main() { 'Run ${installation.upgradeCommand} to update.', ), ).called(1); + verify(() => logger.info(UpdateCommand.reinstallHint)).called(1); verifyNever(() => logger.progress('Updating to $latestVersion')); verifyNever( () => pubUpdater.update( @@ -161,6 +275,7 @@ void main() { } test('does not update when already on latest version', () async { + recordInstall(); when(() => pubUpdater.getLatestVersion(any())) .thenAnswer((_) async => packageVersion); final result = await commandRunner.run(['update']); @@ -174,6 +289,7 @@ void main() { versionConstraint: any(named: 'versionConstraint'), ), ); + expect(started, isEmpty); }); }); }