Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions packages/vgv_ai_cli/lib/src/command_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ class VgvAiCliCommandRunner({
InstallCommand? installCommand,
DoctorCommand? doctorCommand,
UninstallCommand? uninstallCommand,
UpdateCommand? updateCommand,
}) extends CompletionCommandRunner<int> {
/// {@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
Expand All @@ -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,
),
);
}

Expand Down
83 changes: 72 additions & 11 deletions packages/vgv_ai_cli/lib/src/commands/install_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
}
Expand All @@ -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;

Expand Down Expand Up @@ -95,6 +109,8 @@ class InstallCommand({
Future<int> 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.
Expand All @@ -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(
Expand All @@ -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) {
Comment thread
ryzizub marked this conversation as resolved.
Resolved
_logger.err(
'No plugin selected. Choose at least one, or pass --plugin to name '
Expand Down Expand Up @@ -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<String> _recordedPlugins(
InstallManifest manifest,
Set<Harness> selected,
) {
final recorded = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still the union-widening problem from the last review; it has moved here from update. _recordedPlugins merges every selected harness's plugins into one set, and translate and _pluginVersions then apply that set to every harness in selected.

Example: the manifest records claude: {vgv-wingspan} and codex: {vgv-wingspan, vgv-ffca-plugin}. update then installs vgv-ffca-plugin for claude and records it under claude, so each later refresh keeps it there.

Now that --refresh runs inside the new version, a fix doesn't need multiple child processes. Two options:

  • Group the harnesses by their recorded, still-pinned plugin set, then run resolve, translate and reconcile once per group.
  • Translate the full union, then keep only each harness's own plugins when writing the plan and calling _pluginVersions.

It's also worth giving the two harnesses different plugin sets in reinstalls the recorded harnesses and plugins, so the test fails on the current behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. install applies one plugin selection across every harness it configures, and --refresh keeps that model. Per-harness plugin sets only arise from separate install --only runs, and we're not changing that in this PR.

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
Expand Down
68 changes: 60 additions & 8 deletions packages/vgv_ai_cli/lib/src/commands/update_command.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -17,6 +23,9 @@ class UpdateCommand({
required final Logger _logger,
PubUpdater? pubUpdater,
Installation? installation,
final Directory? _home,
final Map<String, String>? _environment,
final StartProcess _startProcess = Process.start,
}) extends Command<int> {
/// {@macro update_command}
///
Expand All @@ -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<int> run() async {
final updateCheckProgress = _logger.progress('Checking for updates');
Expand All @@ -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<bool> _update(String latestVersion) async {
final updateProgress = _logger.progress('Updating to $latestVersion');

late final ProcessResult result;
Expand All @@ -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<int> _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;
}
}
}
7 changes: 7 additions & 0 deletions packages/vgv_ai_cli/lib/src/model/run_process.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ typedef RunProcess = Future<ProcessResult> Function(
List<String> arguments, {
bool runInShell,
});

/// Signature of [Process.start], narrowed to what the CLI needs.
typedef StartProcess = Future<Process> Function(
String executable,
List<String> arguments, {
ProcessStartMode mode,
});
4 changes: 4 additions & 0 deletions packages/vgv_ai_cli/test/helpers/test_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ class TestRunner() {
InstallCommand? installCommand,
DoctorCommand? doctorCommand,
UninstallCommand? uninstallCommand,
UpdateCommand? updateCommand,
}) => VgvAiCliCommandRunner(
logger: logger,
pubUpdater: pubUpdater,
installation: installation,
installCommand: installCommand,
doctorCommand: doctorCommand,
uninstallCommand: uninstallCommand,
updateCommand: updateCommand,
);

/// Runs [args] through a runner from [build].
Expand All @@ -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);
}
11 changes: 10 additions & 1 deletion packages/vgv_ai_cli/test/src/command_runner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand Down
Loading
Loading