From 4b3cc1c60606102b42806e80c3863b73fc81372a Mon Sep 17 00:00:00 2001 From: Dominik Simonik Date: Thu, 12 Nov 2026 14:00:47 +0100 Subject: [PATCH 1/5] feat: reinstall plugins after updating the CLI --- .../vgv_ai_cli/lib/src/command_runner.dart | 19 ++- .../lib/src/commands/update_command.dart | 66 +++++++- .../vgv_ai_cli/lib/src/model/run_process.dart | 7 + .../vgv_ai_cli/test/helpers/test_runner.dart | 4 + .../test/src/command_runner_test.dart | 11 +- .../src/commands/update_command_test.dart | 148 +++++++++++++++--- 6 files changed, 216 insertions(+), 39 deletions(-) diff --git a/packages/vgv_ai_cli/lib/src/command_runner.dart b/packages/vgv_ai_cli/lib/src/command_runner.dart index a642f86d..6e7ae289 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/update_command.dart b/packages/vgv_ai_cli/lib/src/commands/update_command.dart index ebecf026..11deb463 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 +/// plugins a previous `install` wrote, so they move to 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,43 @@ 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 => switch (commandHome(_home, _environment)) { + final home? => ManifestStore(home).file.existsSync(), + null => false, + }; - return ExitCode.success.code; + /// Runs `install` from the version just activated, which carries the new + /// pins, in this terminal so it can ask which plugins to keep. + Future _reinstall() async { + _logger.info('Updating your installed plugins...'); + try { + final process = await _startProcess(Platform.resolvedExecutable, [ + 'pub', + 'global', + 'run', + '$packageName:$executableName', + InstallCommand.commandName, + ], 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 de9ee4d1..a5fb2003 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 ff909bd7..6739b982 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 7a55454e..e6b7c7f7 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/update_command_test.dart b/packages/vgv_ai_cli/test/src/commands/update_command_test.dart index 4df35f9c..e5394d64 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,74 @@ 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; + void main() { group(UpdateCommand, () { const latestVersion = '0.0.0'; + const reinstallArguments = [ + 'pub', + 'global', + 'run', + 'vgv_ai_cli:vgv_ai', + 'install', + ]; late PubUpdater pubUpdater; late Logger logger; late TestRunner runner; + late Directory home; + late Process process; + late List<(String, List, ProcessStartMode)> started; late VgvAiCliCommandRunner commandRunner; + Future startProcess( + String executable, + List arguments, { + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + started.add((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 +101,7 @@ void main() { }); test('handles pub update errors', () async { + recordInstall(); when(() => pubUpdater.getLatestVersion(any())) .thenAnswer((_) async => latestVersion); when( @@ -74,10 +120,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 +148,82 @@ 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 (executable, arguments, mode) = started.single; + expect(executable, Platform.resolvedExecutable); + expect(arguments, reinstallArguments); + expect(mode, ProcessStartMode.inheritStdio); + }); + + 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 +241,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 +263,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 +277,7 @@ void main() { versionConstraint: any(named: 'versionConstraint'), ), ); + expect(started, isEmpty); }); }); } From 67799b2b6474082b64cc8bd3964263791ab14160 Mon Sep 17 00:00:00 2001 From: Dominik Simonik Date: Thu, 12 Nov 2026 14:32:02 +0100 Subject: [PATCH 2/5] refactor: replace record and pattern in update reinstall --- .../lib/src/commands/update_command.dart | 8 ++++---- .../src/commands/update_command_test.dart | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) 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 11deb463..7944113b 100644 --- a/packages/vgv_ai_cli/lib/src/commands/update_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/update_command.dart @@ -112,10 +112,10 @@ class UpdateCommand({ } /// Whether a previous `install` left a manifest behind. - bool get _wasInstalled => switch (commandHome(_home, _environment)) { - final home? => ManifestStore(home).file.existsSync(), - null => false, - }; + bool get _wasInstalled { + final home = commandHome(_home, _environment); + return home != null && ManifestStore(home).file.existsSync(); + } /// Runs `install` from the version just activated, which carries the new /// pins, in this terminal so it can ask which plugins to keep. 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 e5394d64..1813aabd 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 @@ -15,6 +15,13 @@ 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'; @@ -31,7 +38,7 @@ void main() { late TestRunner runner; late Directory home; late Process process; - late List<(String, List, ProcessStartMode)> started; + late List<_Start> started; late VgvAiCliCommandRunner commandRunner; Future startProcess( @@ -39,7 +46,7 @@ void main() { List arguments, { ProcessStartMode mode = ProcessStartMode.normal, }) async { - started.add((executable, arguments, mode)); + started.add(_Start(executable, arguments, mode)); return process; } @@ -176,10 +183,10 @@ void main() { expect(result, ExitCode.success.code); verify(() => logger.info('Updating your installed plugins...')) .called(1); - final (executable, arguments, mode) = started.single; - expect(executable, Platform.resolvedExecutable); - expect(arguments, reinstallArguments); - expect(mode, ProcessStartMode.inheritStdio); + final start = started.single; + expect(start.executable, Platform.resolvedExecutable); + expect(start.arguments, reinstallArguments); + expect(start.mode, ProcessStartMode.inheritStdio); }); test('returns the exit code of the reinstall', () async { From c826ea706290af60d804f2b229f6884889bc79dd Mon Sep 17 00:00:00 2001 From: Dominik Simonik Date: Fri, 13 Nov 2026 13:20:15 +0100 Subject: [PATCH 3/5] fix: reinstall only the recorded harnesses and plugins --- .../lib/src/commands/update_command.dart | 38 +++++++--- .../src/commands/update_command_test.dart | 72 +++++++++++++++---- 2 files changed, 90 insertions(+), 20 deletions(-) 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 7944113b..7f864db4 100644 --- a/packages/vgv_ai_cli/lib/src/commands/update_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/update_command.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import 'package:file/file.dart' show Directory; +import 'package:file/file.dart' show Directory, FileSystemException; import 'package:mason_logger/mason_logger.dart'; import 'package:pub_updater/pub_updater.dart'; import 'package:vgv_ai_cli/src/command_runner.dart'; @@ -82,7 +82,10 @@ class UpdateCommand({ final updated = await _update(latestVersion); if (!updated) return ExitCode.software.code; - return _wasInstalled ? await _reinstall() : ExitCode.success.code; + final manifest = _previousInstall; + return manifest == null + ? ExitCode.success.code + : await _reinstall(manifest); } /// Updates the CLI to [latestVersion], and returns whether it could. @@ -111,15 +114,22 @@ class UpdateCommand({ return true; } - /// Whether a previous `install` left a manifest behind. - bool get _wasInstalled { + /// The manifest a previous `install` left behind, or null when there is + /// none this version can read. + InstallManifest? get _previousInstall { final home = commandHome(_home, _environment); - return home != null && ManifestStore(home).file.existsSync(); + if (home == null) return null; + final store = ManifestStore(home); + try { + return store.file.existsSync() ? store.load() : null; + } on FileSystemException { + return null; + } } /// Runs `install` from the version just activated, which carries the new - /// pins, in this terminal so it can ask which plugins to keep. - Future _reinstall() async { + /// pins, for the harnesses and plugins [manifest] records. + Future _reinstall(InstallManifest manifest) async { _logger.info('Updating your installed plugins...'); try { final process = await _startProcess(Platform.resolvedExecutable, [ @@ -127,7 +137,7 @@ class UpdateCommand({ 'global', 'run', '$packageName:$executableName', - InstallCommand.commandName, + ..._installArguments(manifest), ], mode: ProcessStartMode.inheritStdio); return await process.exitCode; } on ProcessException catch (error) { @@ -137,4 +147,16 @@ class UpdateCommand({ return ExitCode.software.code; } } + + /// The `install` arguments that repeat the choices [manifest] records, so + /// the reinstall neither asks again nor widens them. + List _installArguments(InstallManifest manifest) => [ + if (globalResults?['verbose'] == true) '--verbose', + InstallCommand.commandName, + for (final harness in manifest.plugins.keys) '--only=${harness.name}', + for (final plugin in { + for (final plugins in manifest.plugins.values) ...plugins.keys, + }) + '--plugin=$plugin', + ]; } 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 1813aabd..03b273bc 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,6 +1,6 @@ import 'dart:io'; -import 'package:file/file.dart' show Directory; +import 'package:file/file.dart' show Directory, FileSystemException; import 'package:file/memory.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -9,6 +9,7 @@ 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/model/model.dart'; import 'package:vgv_ai_cli/src/version.dart'; import '../../helpers/test_runner.dart'; @@ -25,13 +26,7 @@ class _Start( void main() { group(UpdateCommand, () { const latestVersion = '0.0.0'; - const reinstallArguments = [ - 'pub', - 'global', - 'run', - 'vgv_ai_cli:vgv_ai', - 'install', - ]; + const runNewVersion = ['pub', 'global', 'run', 'vgv_ai_cli:vgv_ai']; late PubUpdater pubUpdater; late Logger logger; @@ -62,9 +57,18 @@ void main() { startProcess: startProcess, ); - void recordInstall() => ManifestStore(home).file - ..createSync(recursive: true) - ..writeAsStringSync('{}'); + /// Records an install of [plugins], by harness, as `install` would. + void recordInstall([ + Map> plugins = const {}, + ]) => ManifestStore(home).save( + InstallManifest([ + OwnedFile.of( + path: home.fileSystem.path.join(home.path, '.claude', 'x.md'), + owner: Harness.claude, + bytes: const [], + ), + ], plugins), + ); setUp(() { runner = TestRunner(); @@ -185,10 +189,54 @@ void main() { .called(1); final start = started.single; expect(start.executable, Platform.resolvedExecutable); - expect(start.arguments, reinstallArguments); + expect(start.arguments, [...runNewVersion, 'install']); expect(start.mode, ProcessStartMode.inheritStdio); }); + test('reinstalls just the recorded harnesses and plugins', () async { + recordInstall({ + Harness.claude: {'vgv-wingspan': '1.0.0'}, + Harness.codex: {'vgv-wingspan': '1.0.0', 'vgv-ffca-plugin': '1.0.0'}, + }); + await commandRunner.run(['update']); + expect(started.single.arguments, [ + ...runNewVersion, + 'install', + '--only=claude', + '--only=codex', + '--plugin=vgv-wingspan', + '--plugin=vgv-ffca-plugin', + ]); + }); + + test('reinstalls verbosely when updating verbosely', () async { + recordInstall(); + await commandRunner.run(['--verbose', 'update']); + expect(started.single.arguments, [ + ...runNewVersion, + '--verbose', + 'install', + ]); + }); + + test('does not reinstall when the manifest cannot be read', () async { + final fs = MemoryFileSystem.test( + opHandle: (path, operation) { + if (path.endsWith('manifest.json') && + operation == FileSystemOp.read) { + throw FileSystemException('Cannot read', path); + } + }, + ); + home = fs.directory('/home/dev')..createSync(recursive: true); + recordInstall(); + final result = await runner.run([ + 'update', + ], updateCommand: updateCommand(home: home)); + expect(result, ExitCode.success.code); + expect(started, isEmpty); + }); + test('returns the exit code of the reinstall', () async { recordInstall(); when(() => process.exitCode) From c32b9d52a36cadf8b15aa2c446f8037c1c240128 Mon Sep 17 00:00:00 2001 From: Dominik Simonik Date: Fri, 13 Nov 2026 13:58:11 +0100 Subject: [PATCH 4/5] fix: reinstall with a plain install, as on main --- .../lib/src/commands/update_command.dart | 39 ++++---------- .../src/commands/update_command_test.dart | 52 ++----------------- 2 files changed, 13 insertions(+), 78 deletions(-) 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 7f864db4..881f5b40 100644 --- a/packages/vgv_ai_cli/lib/src/commands/update_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/update_command.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import 'package:file/file.dart' show Directory, FileSystemException; +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'; @@ -82,10 +82,7 @@ class UpdateCommand({ final updated = await _update(latestVersion); if (!updated) return ExitCode.software.code; - final manifest = _previousInstall; - return manifest == null - ? ExitCode.success.code - : await _reinstall(manifest); + return _wasInstalled ? await _reinstall() : ExitCode.success.code; } /// Updates the CLI to [latestVersion], and returns whether it could. @@ -114,22 +111,15 @@ class UpdateCommand({ return true; } - /// The manifest a previous `install` left behind, or null when there is - /// none this version can read. - InstallManifest? get _previousInstall { + /// Whether a previous `install` left a manifest behind. + bool get _wasInstalled { final home = commandHome(_home, _environment); - if (home == null) return null; - final store = ManifestStore(home); - try { - return store.file.existsSync() ? store.load() : null; - } on FileSystemException { - return null; - } + return home != null && ManifestStore(home).file.existsSync(); } /// Runs `install` from the version just activated, which carries the new - /// pins, for the harnesses and plugins [manifest] records. - Future _reinstall(InstallManifest manifest) async { + /// pins, in this terminal so it can ask which plugins to keep. + Future _reinstall() async { _logger.info('Updating your installed plugins...'); try { final process = await _startProcess(Platform.resolvedExecutable, [ @@ -137,7 +127,8 @@ class UpdateCommand({ 'global', 'run', '$packageName:$executableName', - ..._installArguments(manifest), + if (globalResults?['verbose'] == true) '--verbose', + InstallCommand.commandName, ], mode: ProcessStartMode.inheritStdio); return await process.exitCode; } on ProcessException catch (error) { @@ -147,16 +138,4 @@ class UpdateCommand({ return ExitCode.software.code; } } - - /// The `install` arguments that repeat the choices [manifest] records, so - /// the reinstall neither asks again nor widens them. - List _installArguments(InstallManifest manifest) => [ - if (globalResults?['verbose'] == true) '--verbose', - InstallCommand.commandName, - for (final harness in manifest.plugins.keys) '--only=${harness.name}', - for (final plugin in { - for (final plugins in manifest.plugins.values) ...plugins.keys, - }) - '--plugin=$plugin', - ]; } 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 03b273bc..8da79532 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,6 +1,6 @@ import 'dart:io'; -import 'package:file/file.dart' show Directory, FileSystemException; +import 'package:file/file.dart' show Directory; import 'package:file/memory.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -9,7 +9,6 @@ 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/model/model.dart'; import 'package:vgv_ai_cli/src/version.dart'; import '../../helpers/test_runner.dart'; @@ -57,18 +56,9 @@ void main() { startProcess: startProcess, ); - /// Records an install of [plugins], by harness, as `install` would. - void recordInstall([ - Map> plugins = const {}, - ]) => ManifestStore(home).save( - InstallManifest([ - OwnedFile.of( - path: home.fileSystem.path.join(home.path, '.claude', 'x.md'), - owner: Harness.claude, - bytes: const [], - ), - ], plugins), - ); + void recordInstall() => ManifestStore(home).file + ..createSync(recursive: true) + ..writeAsStringSync('{}'); setUp(() { runner = TestRunner(); @@ -193,22 +183,6 @@ void main() { expect(start.mode, ProcessStartMode.inheritStdio); }); - test('reinstalls just the recorded harnesses and plugins', () async { - recordInstall({ - Harness.claude: {'vgv-wingspan': '1.0.0'}, - Harness.codex: {'vgv-wingspan': '1.0.0', 'vgv-ffca-plugin': '1.0.0'}, - }); - await commandRunner.run(['update']); - expect(started.single.arguments, [ - ...runNewVersion, - 'install', - '--only=claude', - '--only=codex', - '--plugin=vgv-wingspan', - '--plugin=vgv-ffca-plugin', - ]); - }); - test('reinstalls verbosely when updating verbosely', () async { recordInstall(); await commandRunner.run(['--verbose', 'update']); @@ -219,24 +193,6 @@ void main() { ]); }); - test('does not reinstall when the manifest cannot be read', () async { - final fs = MemoryFileSystem.test( - opHandle: (path, operation) { - if (path.endsWith('manifest.json') && - operation == FileSystemOp.read) { - throw FileSystemException('Cannot read', path); - } - }, - ); - home = fs.directory('/home/dev')..createSync(recursive: true); - recordInstall(); - final result = await runner.run([ - 'update', - ], updateCommand: updateCommand(home: home)); - expect(result, ExitCode.success.code); - expect(started, isEmpty); - }); - test('returns the exit code of the reinstall', () async { recordInstall(); when(() => process.exitCode) From 7c4bc965c3ab5b61020b7c816a105d3fe420702c Mon Sep 17 00:00:00 2001 From: Dominik Simonik Date: Fri, 13 Nov 2026 14:54:21 +0100 Subject: [PATCH 5/5] feat: reinstall recorded harnesses and plugins with install --refresh --- .../lib/src/commands/install_command.dart | 83 +++++++++++++++--- .../lib/src/commands/update_command.dart | 7 +- .../src/commands/install_command_test.dart | 86 +++++++++++++++++++ .../src/commands/update_command_test.dart | 3 +- 4 files changed, 164 insertions(+), 15 deletions(-) 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 dd061866..5204e26d 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 881f5b40..a3b6e3ba 100644 --- a/packages/vgv_ai_cli/lib/src/commands/update_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/update_command.dart @@ -14,7 +14,7 @@ import 'package:vgv_ai_cli/src/version.dart'; /// {@template update_command} /// Updates `vgv_ai` to the latest version on pub.dev, then reinstalls the -/// plugins a previous `install` wrote, so they move to the new pins. +/// 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. @@ -117,8 +117,8 @@ class UpdateCommand({ return home != null && ManifestStore(home).file.existsSync(); } - /// Runs `install` from the version just activated, which carries the new - /// pins, in this terminal so it can ask which plugins to keep. + /// 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 { @@ -129,6 +129,7 @@ class UpdateCommand({ '$packageName:$executableName', if (globalResults?['verbose'] == true) '--verbose', InstallCommand.commandName, + '--${InstallCommand.refreshFlag}', ], mode: ProcessStartMode.inheritStdio); return await process.exitCode; } on ProcessException catch (error) { 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 4e1c51f7..40221044 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 8da79532..eba12e05 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 @@ -179,7 +179,7 @@ void main() { .called(1); final start = started.single; expect(start.executable, Platform.resolvedExecutable); - expect(start.arguments, [...runNewVersion, 'install']); + expect(start.arguments, [...runNewVersion, 'install', '--refresh']); expect(start.mode, ProcessStartMode.inheritStdio); }); @@ -190,6 +190,7 @@ void main() { ...runNewVersion, '--verbose', 'install', + '--refresh', ]); });