Repository: VeryGoodOpenSource/very_good_cli (dart)

Issue:
Add in user survey prompt for VG CLI
As VGV,
I want to have a message show up in the console output during installation and updates that prompts users to answer a survey about their experience,
to better record user feedback and improve the product.

The following prompt should be used: (need to update link)

“Thank you for using Very Good CLI from Very Good Ventures. If you want to stay in touch with us and get information on future updates please go to \<CLICKABLE LINK HERE\> to join our newsletter.”

For installation, we will check if the completion is there. If there aren't any CLI completion files yet, we can output the message in the console.

For update, we can add it to the output of the update command.

Pull request:
<!--
  Thanks for contributing!

  Provide a description of your changes below and a general summary in the title

  Please look at the following checklist to ensure that your PR can be accepted quickly:
-->

## Description

![image](https://github.com/VeryGoodOpenSource/very_good_cli/assets/15887627/7905f2ce-77b6-4a1d-95f3-366b8ed50f56)

Closes #812 

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x] ✨ New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore

Patch:
diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart
index 2ae03426d..53418ae2a 100644
--- a/lib/src/command_runner.dart
+++ b/lib/src/command_runner.dart
@@ -3,8 +3,11 @@ import 'package:args/command_runner.dart';
 import 'package:cli_completion/cli_completion.dart';
 import 'package:mason/mason.dart' hide packageVersion;
 import 'package:meta/meta.dart';
+import 'package:path/path.dart' as path;
 import 'package:pub_updater/pub_updater.dart';
+import 'package:universal_io/io.dart';
 import 'package:very_good_cli/src/commands/commands.dart';
+import 'package:very_good_cli/src/logger_extension.dart';
 import 'package:very_good_cli/src/pub_license/pub_license.dart';
 import 'package:very_good_cli/src/version.dart';
 
@@ -19,9 +22,11 @@ class VeryGoodCommandRunner extends CompletionCommandRunner<int> {
   VeryGoodCommandRunner({
     Logger? logger,
     PubUpdater? pubUpdater,
+    Map<String, String>? environment,
     @visibleForTesting PubLicense? pubLicense,
   })  : _logger = logger ?? Logger(),
         _pubUpdater = pubUpdater ?? PubUpdater(),
+        _environment = environment ?? Platform.environment,
         super('very_good', '🦄 A Very Good Command-Line Interface') {
     argParser
       ..addFlag(
@@ -45,6 +50,16 @@ class VeryGoodCommandRunner extends CompletionCommandRunner<int> {
   final Logger _logger;
   final PubUpdater _pubUpdater;
 
+  /// Map of environments information.
+  Map<String, String> get environment => environmentOverride ?? _environment;
+  final Map<String, String> _environment;
+
+  /// Boolean for checking if windows, which can be overridden for
+  /// testing purposes.
+  @visibleForTesting
+  bool? isWindowsOverride;
+  bool get _isWindows => isWindowsOverride ?? Platform.isWindows;
+
   @override
   void printUsage() => _logger.info(usage);
 
@@ -115,6 +130,7 @@ class VeryGoodCommandRunner extends CompletionCommandRunner<int> {
     if (topLevelResults.command?.name != UpdateCommand.commandName) {
       await _checkForUpdates();
     }
+    _showThankYou();
     return exitCode;
   }
 
@@ -134,4 +150,39 @@ Run ${lightCyan.wrap('very_good update')} to update''',
       }
     } catch (_) {}
   }
+
+  void _showThankYou() {
+    if (environment.containsKey('CI')) return;
+
+    final versionFile = File(
+      path.join(_configDir.path, 'version'),
+    )..createSync(recursive: true);
+
+    if (versionFile.readAsStringSync() == packageVersion) return;
+    versionFile.writeAsStringSync(packageVersion);
+
+    _logger.wrap(
+      lightMagenta.wrap('''
+
+Thank you for using Very Good Ventures open source tools!
+Don't forget to fill out this form to get information on future updates and releases here: ${lightBlue.wrap(link(uri: Uri.parse('https://verygood.ventures/open-source/cli/subscribe-latest-tool-updates')))}'''),
+      print: _logger.info,
+    );
+  }
+
+  Directory get _configDir {
+    if (_isWindows) {
+      // Use localappdata on windows
+      final localAppData = environment['LOCALAPPDATA']!;
+      return Directory(path.join(localAppData, 'VeryGoodCLI'));
+    } else {
+      // Try using XDG config folder
+      var dirPath = environment['XDG_CONFIG_HOME'];
+      // Fallback to $HOME if not following XDG specification
+      if (dirPath == null || dirPath.isEmpty) {
+        dirPath = environment['HOME'];
+      }
+      return Directory(path.join(dirPath!, '.very_good_cli'));
+    }
+  }
 }
diff --git a/lib/src/logger_extension.dart b/lib/src/logger_extension.dart
index 368438ad8..da7ce8a59 100644
--- a/lib/src/logger_extension.dart
+++ b/lib/src/logger_extension.dart
@@ -1,4 +1,5 @@
 import 'package:mason_logger/mason_logger.dart';
+import 'package:universal_io/io.dart';
 
 /// Extension on the Logger class for custom styled logging.
 extension LoggerX on Logger {
@@ -6,4 +7,35 @@ extension LoggerX on Logger {
   void created(String message) {
     info(lightCyan.wrap(styleBold.wrap(message)));
   }
+
+  /// Wrap the [text] to fit perfectly within the width of the terminal when
+  /// [print]ed.
+  ///
+  /// To overwrite the width you can use [length].
+  void wrap(
+    String? text, {
+    required void Function(String?) print,
+    int? length,
+  }) {
+    final maxLength = length ?? stdout.terminalColumns;
+    for (final sentence in text?.split('/n') ?? <String>[]) {
+      final words = sentence.split(' ');
+
+      final currentLine = StringBuffer();
+      for (final word in words) {
+        // Replace all ANSI sequences so we can get the true character length.
+        final charLength = word
+            .replaceAll(RegExp('\x1B(?:[@-Z\\-_]|[[0-?]*[ -/]*[@-~])'), '')
+            .length;
+
+        if (currentLine.length + charLength > maxLength) {
+          print(currentLine.toString());
+          currentLine.clear();
+        }
+        currentLine.write('$word ');
+      }
+
+      print(currentLine.toString());
+    }
+  }
 }


Test patch:
diff --git a/e2e/helpers/command_helper.dart b/e2e/helpers/command_helper.dart
index 5650369c5..3920f25f5 100644
--- a/e2e/helpers/command_helper.dart
+++ b/e2e/helpers/command_helper.dart
@@ -42,6 +42,7 @@ void Function() withRunner(
     final commandRunner = VeryGoodCommandRunner(
       logger: logger,
       pubUpdater: pubUpdater,
+      environment: {'CI': 'true'},
     );
 
     when(() => progress.complete(any())).thenAnswer((_) {
diff --git a/test/helpers/command_helper.dart b/test/helpers/command_helper.dart
index 94bc75dde..3bd142861 100644
--- a/test/helpers/command_helper.dart
+++ b/test/helpers/command_helper.dart
@@ -47,6 +47,7 @@ void Function() withRunner(
     final commandRunner = VeryGoodCommandRunner(
       logger: logger,
       pubUpdater: pubUpdater,
+      environment: {'CI': 'true'},
       pubLicense: pubLicense,
     );
 
diff --git a/test/src/command_runner_test.dart b/test/src/command_runner_test.dart
index 0dc703af1..b17812046 100644
--- a/test/src/command_runner_test.dart
+++ b/test/src/command_runner_test.dart
@@ -16,6 +16,12 @@ class _MockPubUpdater extends Mock implements PubUpdater {}
 
 class _MockProgress extends Mock implements Progress {}
 
+class _MockDirectory extends Mock implements Directory {}
+
+class _MockFile extends Mock implements File {}
+
+class _MockStdout extends Mock implements Stdout {}
+
 const expectedUsage = [
   '🦄 A Very Good Command-Line Interface\n'
       '\n'
@@ -71,6 +77,7 @@ void main() {
       commandRunner = VeryGoodCommandRunner(
         logger: logger,
         pubUpdater: pubUpdater,
+        environment: {'CI': 'true'},
       );
     });
 
@@ -179,6 +186,114 @@ void main() {
         expect(result, equals(ExitCode.success.code));
       });
 
+      group('_showThankYou', () {
+        late Directory cliCache;
+        late File versionFile;
+        late Stdout stdout;
+
+        setUp(() {
+          cliCache = _MockDirectory();
+          when(() => cliCache.path).thenReturn('/users/test');
+
+          versionFile = _MockFile();
+          when(() => versionFile.readAsStringSync()).thenReturn('0.0.0');
+
+          stdout = _MockStdout();
+          when(() => stdout.supportsAnsiEscapes).thenReturn(true);
+          when(() => stdout.terminalColumns).thenReturn(30);
+        });
+
+        test('shows message when version changed', () async {
+          commandRunner.environmentOverride = {
+            'HOME': '/users/test',
+          };
+
+          await IOOverrides.runZoned(
+            () async {
+              final result = await commandRunner.run([]);
+              expect(result, equals(ExitCode.success.code));
+
+              verifyInOrder([
+                () => logger.info('\nThank you for using Very Good '),
+                () => logger.info('Ventures open source '),
+                () => logger.info("tools!\nDon't forget to fill "),
+                () => logger.info('out this form to get '),
+                () => logger.info('information on future updates '),
+                () => logger.info('and releases here: '),
+                () => logger.info(
+                      any(
+                        that: contains(
+                          'https://verygood.ventures/open-source/cli/subscribe-latest-tool-updates',
+                        ),
+                      ),
+                    ),
+              ]);
+
+              verify(
+                () => versionFile.createSync(
+                  recursive: any(that: isTrue, named: 'recursive'),
+                ),
+              ).called(1);
+              verify(() => versionFile.readAsStringSync()).called(1);
+              verify(
+                () => versionFile
+                    .writeAsStringSync(any(that: equals(packageVersion))),
+              ).called(1);
+            },
+            createDirectory: (path) => cliCache,
+            createFile: (path) => versionFile,
+            stdout: () => stdout,
+          );
+        });
+
+        test('cache inside XDG directory', () async {
+          commandRunner.environmentOverride = {
+            'HOME': '/users/test',
+            'XDG_CONFIG_HOME': '/users/test/.xdg',
+          };
+
+          final xdgCache = _MockDirectory();
+          when(() => xdgCache.path).thenReturn('/users/test/.xdg');
+
+          await IOOverrides.runZoned(
+            () async {
+              final result = await commandRunner.run([]);
+              expect(result, equals(ExitCode.success.code));
+
+              verifyNever(() => cliCache.path);
+              verify(() => xdgCache.path).called(1);
+            },
+            createDirectory: (path) =>
+                path.contains('.xdg') ? xdgCache : cliCache,
+            createFile: (path) => versionFile,
+            stdout: () => stdout,
+          );
+        });
+
+        test('cache inside local APP_DATA on windows', () async {
+          commandRunner
+            ..environmentOverride = {'LOCALAPPDATA': '/C/Users/test'}
+            ..isWindowsOverride = true;
+
+          final windowsCache = _MockDirectory();
+          when(() => windowsCache.path).thenReturn('/C/Users/test');
+
+          await IOOverrides.runZoned(
+            () async {
+              final result = await commandRunner.run([]);
+              expect(result, equals(ExitCode.success.code));
+
+              verifyNever(() => cliCache.path);
+              verify(() => windowsCache.path).called(1);
+            },
+            createDirectory: (path) =>
+                path.startsWith('/C/') ? windowsCache : cliCache,
+            createFile: (path) => versionFile,
+            stdout: () => stdout,
+          );
+        });
+      });
+
       group('--help', () {
         test('outputs usage', () async {
           final result = await commandRunner.run(['--help']);
diff --git a/test/src/logger_extension_test.dart b/test/src/logger_extension_test.dart
new file mode 100644
index 000000000..c1a565399
--- /dev/null
+++ b/test/src/logger_extension_test.dart
@@ -0,0 +1,50 @@
+import 'package:mason/mason.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:test/test.dart';
+import 'package:very_good_cli/src/logger_extension.dart';
+
+class _MockLogger extends Mock implements Logger {}
+
+void main() {
+  group('LoggerX', () {
+    late Logger logger;
+
+    setUp(() {
+      logger = _MockLogger();
+    });
+
+    test('created', () {
+      logger.created('test');
+
+      verify(
+        () => logger.info(
+          any(that: equals(lightCyan.wrap(styleBold.wrap('test')))),
+        ),
+      ).called(1);
+    });
+
+    group('wrap', () {
+      test('normally across two separate prints', () {
+        logger.wrap('1 2 3 4 5 1 2 3 4 5', print: logger.info, length: 10);
+
+        verifyInOrder([
+          () => logger.info(any(that: equals('1 2 3 4 5 '))),
+          () => logger.info(any(that: equals('1 2 3 4 5 '))),
+        ]);
+      });
+
+      test('across two separate prints while not adding in ANSI encoding', () {
+        logger.wrap(
+          '${lightCyan.wrap('1 2 3 4 5')} 1 2 3 4 5',
+          print: logger.info,
+          length: 10,
+        );
+
+        verifyInOrder([
+          () => logger.info(any(that: equals(lightCyan.wrap('1 2 3 4 5 ')))),
+          () => logger.info(any(that: equals('1 2 3 4 5 '))),
+        ]);
+      });
+    });
+  });
+}