From 4a7f1752bbf09ae750c426f437a0b634f26f50bc Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 15:27:10 +0100 Subject: [PATCH 01/11] feat: Adds support for PostHog analytics. --- packages/cli_tools/lib/analytics.dart | 2 + .../lib/src/analytics/analytics.dart | 124 +++------ .../lib/src/analytics/command_properties.dart | 207 +++++++++++++++ .../cli_tools/lib/src/analytics/helpers.dart | 13 + .../cli_tools/lib/src/analytics/mixpanel.dart | 99 +++++++ .../cli_tools/lib/src/analytics/posthog.dart | 90 +++++++ .../better_command_runner.dart | 22 +- .../analytics/command_properties_test.dart | 242 ++++++++++++++++++ .../better_command_runner/analytics_test.dart | 8 +- .../better_command_test.dart | 4 +- .../default_flags_test.dart | 2 +- 11 files changed, 715 insertions(+), 98 deletions(-) create mode 100644 packages/cli_tools/lib/src/analytics/command_properties.dart create mode 100644 packages/cli_tools/lib/src/analytics/helpers.dart create mode 100644 packages/cli_tools/lib/src/analytics/mixpanel.dart create mode 100644 packages/cli_tools/lib/src/analytics/posthog.dart create mode 100644 packages/cli_tools/test/analytics/command_properties_test.dart diff --git a/packages/cli_tools/lib/analytics.dart b/packages/cli_tools/lib/analytics.dart index 2a12ffb..b219f6f 100644 --- a/packages/cli_tools/lib/analytics.dart +++ b/packages/cli_tools/lib/analytics.dart @@ -1 +1,3 @@ export 'src/analytics/analytics.dart'; +export 'src/analytics/mixpanel.dart'; +export 'src/analytics/posthog.dart'; diff --git a/packages/cli_tools/lib/src/analytics/analytics.dart b/packages/cli_tools/lib/src/analytics/analytics.dart index 0fa718a..d59f459 100644 --- a/packages/cli_tools/lib/src/analytics/analytics.dart +++ b/packages/cli_tools/lib/src/analytics/analytics.dart @@ -1,106 +1,56 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:ci/ci.dart' as ci; -import 'package:http/http.dart' as http; - /// Interface for analytics services. abstract interface class Analytics { /// Clean up resources. void cleanUp(); /// Track an event. - void track({required final String event}); + void track({ + required final String event, + final Map properties = const {}, + }); + + /// Identifies a user with additional properties (e.g., email). + void identify({ + final String? email, + final Map? properties, + }); } -/// Analytics service for MixPanel. -class MixPanelAnalytics implements Analytics { - static const _defaultEndpoint = 'https://api.mixpanel.com/track'; - static const _defaultTimeout = Duration(seconds: 2); - - final String _uniqueUserId; - final String _projectToken; - final String _version; - - final Uri _endpoint; - final Duration _timeout; +class CompoundAnalytics implements Analytics { + final List providers; - MixPanelAnalytics({ - required final String uniqueUserId, - required final String projectToken, - required final String version, - final String? endpoint, - final Duration timeout = _defaultTimeout, - final bool disableIpTracking = false, - }) : _uniqueUserId = uniqueUserId, - _projectToken = projectToken, - _version = version, - _endpoint = _buildEndpoint( - endpoint ?? _defaultEndpoint, - disableIpTracking, - ), - _timeout = timeout; - - static Uri _buildEndpoint( - final String baseEndpoint, - final bool disableIpTracking, - ) { - final uri = Uri.parse(baseEndpoint); - final ipValue = disableIpTracking ? '0' : '1'; - - final updatedUri = uri.replace( - queryParameters: { - ...uri.queryParameters, - 'ip': ipValue, - }, - ); - return updatedUri; - } + CompoundAnalytics(this.providers); @override - void cleanUp() {} - - @override - void track({required final String event}) { - final payload = jsonEncode({ - 'event': event, - 'properties': { - 'distinct_id': _uniqueUserId, - 'token': _projectToken, - 'platform': _getPlatform(), - 'dart_version': Platform.version, - 'is_ci': ci.isCI, - 'version': _version, - }, - }); - - _quietPost(payload); + void cleanUp() { + for (final provider in providers) { + provider.cleanUp(); + } } - String _getPlatform() { - if (Platform.isMacOS) { - return 'MacOS'; - } else if (Platform.isWindows) { - return 'Windows'; - } else if (Platform.isLinux) { - return 'Linux'; - } else { - return 'Unknown'; + @override + void track({ + required final String event, + final Map properties = const {}, + }) { + for (final provider in providers) { + provider.track( + event: event, + properties: properties, + ); } } - Future _quietPost(final String payload) async { - try { - await http.post( - _endpoint, - body: 'data=$payload', - headers: { - 'Accept': 'text/plain', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - ).timeout(_timeout); - } catch (e) { - return; + @override + void identify({ + final String? email, + final Map? properties, + }) { + for (final provider in providers) { + provider.identify( + email: email, + properties: properties, + ); } } } diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart new file mode 100644 index 0000000..5e0e55f --- /dev/null +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -0,0 +1,207 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; + +Map buildCommandPropertiesForAnalytics({ + required final ArgResults topLevelResults, + required final ArgParser argParser, + required final Map commands, +}) { + const maskedValue = 'xxx'; + final properties = {}; + + // Collect explicitly-provided options/flags and mask any values. + void addOptions(final ArgResults results) { + for (final optionName in results.options) { + if (!results.wasParsed(optionName)) { + continue; + } + final value = results[optionName]; + if (value is bool) { + properties['flag_$optionName'] = value; + } else if (value != null) { + properties['option_$optionName'] = value is List + ? List.filled(value.length, maskedValue) + : maskedValue; + } + } + } + + for (ArgResults? current = topLevelResults; + current != null; + current = current.command) { + addOptions(current); + } + + // Reconstruct the command in user input order, masking values. + properties['full_command'] = _buildFullCommandForAnalytics( + arguments: topLevelResults.arguments, + maskedValue: maskedValue, + argParser: argParser, + commands: commands, + ); + + return properties; +} + +String _buildFullCommandForAnalytics({ + required final List arguments, + required final String maskedValue, + required final ArgParser argParser, + required final Map commands, +}) { + final tokens = []; + var currentParser = argParser; + var currentCommands = commands; + var afterDoubleDash = false; + var expectingValue = false; + + // Use a consistent placeholder for any sensitive tokens. + void addMasked() { + tokens.add(maskedValue); + } + + String? optionNameForAbbreviation( + final ArgParser parser, + final String abbreviation, + ) { + final option = parser.findByAbbreviation(abbreviation); + if (option == null) { + return null; + } + for (final entry in parser.options.entries) { + if (entry.value == option) { + return entry.key; + } + } + return null; + } + + // Normalizes option tokens and tracks whether a value is expected next. + bool handleOption( + final String name, { + required final bool isNegated, + final bool hasInlineValue = false, + }) { + final option = currentParser.options[name]; + if (option == null) { + addMasked(); + return false; + } + if (option.isFlag) { + tokens.add(isNegated ? '--no-$name' : '--$name'); + return true; + } + tokens.add('--$name'); + if (!hasInlineValue) { + expectingValue = true; + } + return true; + } + + for (final arg in arguments) { + if (afterDoubleDash) { + addMasked(); + continue; + } + + if (expectingValue) { + addMasked(); + expectingValue = false; + continue; + } + + if (arg == '--') { + afterDoubleDash = true; + tokens.add('--'); + continue; + } + + if (arg.startsWith('--')) { + // Long options; normalize and mask any provided value. + final withoutPrefix = arg.substring(2); + final equalIndex = withoutPrefix.indexOf('='); + if (equalIndex != -1) { + final name = withoutPrefix.substring(0, equalIndex); + if (handleOption(name, isNegated: false, hasInlineValue: true)) { + addMasked(); + } + continue; + } + + if (withoutPrefix.startsWith('no-')) { + final name = withoutPrefix.substring(3); + handleOption(name, isNegated: true); + continue; + } + + handleOption(withoutPrefix, isNegated: false); + continue; + } + + if (arg.startsWith('-') && arg != '-') { + // Short options; expand to their long form when possible. + final withoutPrefix = arg.substring(1); + final equalIndex = withoutPrefix.indexOf('='); + if (equalIndex != -1) { + final abbreviation = withoutPrefix.substring(0, equalIndex); + final name = optionNameForAbbreviation(currentParser, abbreviation); + if (name == null) { + addMasked(); + continue; + } + if (handleOption(name, isNegated: false, hasInlineValue: true)) { + addMasked(); + } + continue; + } + + if (withoutPrefix.length == 1) { + final name = optionNameForAbbreviation(currentParser, withoutPrefix); + if (name == null) { + addMasked(); + continue; + } + handleOption(name, isNegated: false); + continue; + } + + for (var i = 0; i < withoutPrefix.length; i++) { + final abbreviation = withoutPrefix[i]; + final name = optionNameForAbbreviation(currentParser, abbreviation); + if (name == null) { + addMasked(); + break; + } + final option = currentParser.options[name]; + if (option == null) { + addMasked(); + break; + } + if (option.isFlag) { + tokens.add('--$name'); + continue; + } + tokens.add('--$name'); + if (i < withoutPrefix.length - 1) { + addMasked(); + } else { + expectingValue = true; + } + break; + } + continue; + } + + final command = currentCommands[arg]; + if (command != null) { + tokens.add(arg); + currentParser = command.argParser; + currentCommands = command.subcommands; + continue; + } + + addMasked(); + } + + return tokens.join(' '); +} diff --git a/packages/cli_tools/lib/src/analytics/helpers.dart b/packages/cli_tools/lib/src/analytics/helpers.dart new file mode 100644 index 0000000..ba3b979 --- /dev/null +++ b/packages/cli_tools/lib/src/analytics/helpers.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +String getPlatformString() { + if (Platform.isMacOS) { + return 'MacOS'; + } else if (Platform.isWindows) { + return 'Windows'; + } else if (Platform.isLinux) { + return 'Linux'; + } else { + return 'Unknown'; + } +} diff --git a/packages/cli_tools/lib/src/analytics/mixpanel.dart b/packages/cli_tools/lib/src/analytics/mixpanel.dart new file mode 100644 index 0000000..4a34580 --- /dev/null +++ b/packages/cli_tools/lib/src/analytics/mixpanel.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ci/ci.dart' as ci; +import 'package:http/http.dart' as http; + +import 'analytics.dart'; +import 'helpers.dart'; + +/// Analytics service for MixPanel. +class MixPanelAnalytics implements Analytics { + static const _defaultEndpoint = 'https://api.mixpanel.com/track'; + static const _defaultTimeout = Duration(seconds: 2); + + final String _uniqueUserId; + final String _projectToken; + final String _version; + + final Uri _endpoint; + final Duration _timeout; + + MixPanelAnalytics({ + required final String uniqueUserId, + required final String projectToken, + required final String version, + final String? endpoint, + final Duration timeout = _defaultTimeout, + final bool disableIpTracking = false, + }) : _uniqueUserId = uniqueUserId, + _projectToken = projectToken, + _version = version, + _endpoint = _buildEndpoint( + endpoint ?? _defaultEndpoint, + disableIpTracking, + ), + _timeout = timeout; + + static Uri _buildEndpoint( + final String baseEndpoint, + final bool disableIpTracking, + ) { + final uri = Uri.parse(baseEndpoint); + final ipValue = disableIpTracking ? '0' : '1'; + + final updatedUri = uri.replace( + queryParameters: { + ...uri.queryParameters, + 'ip': ipValue, + }, + ); + return updatedUri; + } + + @override + void cleanUp() {} + + @override + void track({ + required final String event, + final Map properties = const {}, + }) { + final payload = jsonEncode({ + 'event': event, + 'properties': { + 'distinct_id': _uniqueUserId, + 'token': _projectToken, + 'platform': getPlatformString(), + 'dart_version': Platform.version, + 'is_ci': ci.isCI, + 'version': _version, + }, + }); + + _quietPost(payload); + } + + Future _quietPost(final String payload) async { + try { + await http.post( + _endpoint, + body: 'data=$payload', + headers: { + 'Accept': 'text/plain', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ).timeout(_timeout); + } catch (e) { + return; + } + } + + @override + void identify({ + final String? email, + final Map? properties, + }) { + // Identify events are ignored for MixPanel. + } +} diff --git a/packages/cli_tools/lib/src/analytics/posthog.dart b/packages/cli_tools/lib/src/analytics/posthog.dart new file mode 100644 index 0000000..b6e782e --- /dev/null +++ b/packages/cli_tools/lib/src/analytics/posthog.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ci/ci.dart' as ci; +import 'package:http/http.dart' as http; + +import 'analytics.dart'; +import 'helpers.dart'; + +/// Analytics service for PostHog. +class PostHogAnalytics implements Analytics { + static const _defaultHost = 'https://eu.i.posthog.com'; + static const _defaultTimeout = Duration(seconds: 2); + + final String _uniqueUserId; + final String _projectApiKey; + final String _version; + + final Uri _endpoint; + final Duration _timeout; + + PostHogAnalytics({ + required final String uniqueUserId, + required final String projectApiKey, + required final String version, + final String? host, + final Duration timeout = _defaultTimeout, + }) : _uniqueUserId = uniqueUserId, + _projectApiKey = projectApiKey, + _version = version, + _endpoint = Uri.parse('${host ?? _defaultHost}/capture/'), + _timeout = timeout; + + @override + void cleanUp() {} + + @override + void track({ + required final String event, + final Map properties = const {}, + }) { + final eventData = { + 'api_key': _projectApiKey, + 'event': event, + 'distinct_id': _uniqueUserId, + 'properties': { + '\$lib': 'cli_tools', + '\$lib_version': _version, + 'platform': getPlatformString(), + 'dart_version': Platform.version, + 'is_ci': ci.isCI, + ...properties, + }, + }; + + _quietPost(eventData); + } + + @override + void identify({ + final String? email, + final Map? properties, + }) { + final identifyData = { + 'api_key': _projectApiKey, + 'event': '\$identify', + 'distinct_id': _uniqueUserId, + '\$set': { + if (email != null) 'email': email, + ...?properties, + }, + }; + + _quietPost(identifyData); + } + + Future _quietPost(final Map eventData) async { + try { + await http + .post( + _endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(eventData), + ) + .timeout(_timeout); + } catch (e) { + return; + } + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart index 9ea518a..5bf562e 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart @@ -5,6 +5,7 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:config/config.dart'; +import '../analytics/command_properties.dart'; import 'completion/completion_command.dart'; import 'completion/completion_tool.dart' show CompletionScript; @@ -48,7 +49,10 @@ typedef SetLogLevel = void Function({ }); /// A function type for tracking events. -typedef OnAnalyticsEvent = void Function(String event); +typedef OnAnalyticsEvent = void Function( + String event, + Map properties, +); /// An extension of [CommandRunner] with additional features. /// @@ -213,10 +217,15 @@ class BetterCommandRunner /// Invoked from BetterCommandRunner upon command execution /// with the event name, or command name if applicable. /// Can be overridden to customize the event sending behavior. - void sendAnalyticsEvent(final String event) { + void sendAnalyticsEvent( + final String event, [ + final Map properties = const {}, + ]) { + print('sendAnalyticsEvent: $event properties: $properties'); + if (analyticsEnabled()) { try { - onAnalyticsEvent?.call(event); + onAnalyticsEvent?.call(event, properties); } catch (_) { // Silently ignore analytics sending errors to not disrupt the main flow } @@ -326,7 +335,12 @@ class BetterCommandRunner // results there should always be a name specified. assert(command.name != null, 'Command name should never be null.'); sendAnalyticsEvent( - command.name ?? BetterCommandRunnerAnalyticsEvents.invalid, + command.name!, + buildCommandPropertiesForAnalytics( + topLevelResults: topLevelResults, + argParser: argParser, + commands: commands, + ), ); return; } diff --git a/packages/cli_tools/test/analytics/command_properties_test.dart b/packages/cli_tools/test/analytics/command_properties_test.dart new file mode 100644 index 0000000..6bf383f --- /dev/null +++ b/packages/cli_tools/test/analytics/command_properties_test.dart @@ -0,0 +1,242 @@ +import 'package:args/command_runner.dart'; +import 'package:cli_tools/src/analytics/command_properties.dart'; +import 'package:test/test.dart'; + +class TemplateCommand extends Command { + static const commandName = 'template'; + + @override + String get description => 'Template subcommand'; + + @override + String get name => commandName; + + TemplateCommand() { + argParser.addOption('path', abbr: 'p'); + } + + @override + void run() {} +} + +class CreateCommand extends Command { + static const commandName = 'create'; + + @override + String get description => 'Create command'; + + @override + String get name => commandName; + + CreateCommand() { + argParser.addFlag('mini', abbr: 'm', negatable: false); + argParser.addFlag('force', abbr: 'f', defaultsTo: true); + argParser.addOption('name', abbr: 'n'); + argParser.addMultiOption('tag', abbr: 't'); + addSubcommand(TemplateCommand()); + } + + @override + void run() {} +} + +void main() { + group('Given command properties builder', () { + late CommandRunner runner; + + setUp(() { + runner = CommandRunner('tool', 'test cli') + ..addCommand(CreateCommand()); + }); + + test( + 'when running with flag and positional ' + 'then flags are preserved and positionals masked', + () { + final args = [CreateCommand.commandName, '--mini', 'project']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('flag_mini', true)); + expect(properties['full_command'], 'create --mini xxx'); + }, + ); + + test( + 'when running with option value ' + 'then value is masked in properties and full command', + () { + final args = [ + CreateCommand.commandName, + '--mini', + '--name', + 'secret' + ]; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('flag_mini', true)); + expect(properties, containsPair('option_name', 'xxx')); + expect(properties['full_command'], 'create --mini --name xxx'); + }, + ); + + test( + 'when running with inline long option value ' + 'then value is masked and no extra arg is consumed', + () { + final args = [CreateCommand.commandName, '--name=secret', 'extra']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('option_name', 'xxx')); + expect(properties['full_command'], 'create --name xxx xxx'); + }, + ); + + test( + 'when running with inline short option value ' + 'then value is masked', + () { + final args = [CreateCommand.commandName, '-n=secret']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('option_name', 'xxx')); + expect(properties['full_command'], 'create --name xxx'); + }, + ); + + test( + 'when running with negated flag ' + 'then full command preserves negation and property is false', + () { + final args = [CreateCommand.commandName, '--no-force']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('flag_force', false)); + expect(properties['full_command'], 'create --no-force'); + }, + ); + + test( + 'when running with repeated multi options ' + 'then each value is masked', + () { + final args = [ + CreateCommand.commandName, + '--tag', + 'a', + '--tag', + 'b' + ]; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties['option_tag'], equals(['xxx', 'xxx'])); + expect(properties['full_command'], 'create --tag xxx --tag xxx'); + }, + ); + + test( + 'when running with short flag and short option ' + 'then option value is masked', + () { + final args = [CreateCommand.commandName, '-m', '-n', 'secret']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('flag_mini', true)); + expect(properties, containsPair('option_name', 'xxx')); + expect(properties['full_command'], 'create --mini --name xxx'); + }, + ); + + test( + 'when running with subcommand option ' + 'then subcommand parser is used for masking', + () { + final args = [ + CreateCommand.commandName, + TemplateCommand.commandName, + '--path', + '/tmp/secret' + ]; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('option_path', 'xxx')); + expect(properties['full_command'], 'create template --path xxx'); + }, + ); + + test( + 'when running with double dash ' + 'then all following tokens are masked', + () { + final args = [ + CreateCommand.commandName, + '--name', + 'secret', + '--', + '--not-option', + 'literal' + ]; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('option_name', 'xxx')); + expect( + properties['full_command'], + 'create --name xxx -- xxx xxx', + ); + }, + ); + }); +} diff --git a/packages/cli_tools/test/better_command_runner/analytics_test.dart b/packages/cli_tools/test/better_command_runner/analytics_test.dart index fc99d82..dadcf3b 100644 --- a/packages/cli_tools/test/better_command_runner/analytics_test.dart +++ b/packages/cli_tools/test/better_command_runner/analytics_test.dart @@ -78,7 +78,7 @@ void main() { final runner = BetterCommandRunner( 'test', 'this is a test cli', - onAnalyticsEvent: (final event) {}, + onAnalyticsEvent: (final event, final properties) {}, messageOutput: const MessageOutput(), ); @@ -97,7 +97,7 @@ void main() { runner = BetterCommandRunner( 'test', 'this is a test cli', - onAnalyticsEvent: (final event) => events.add(event), + onAnalyticsEvent: (final event, final _) => events.add(event), messageOutput: const MessageOutput(), ); assert(runner.analyticsEnabled()); @@ -210,7 +210,7 @@ void main() { runner = BetterCommandRunner( 'test', 'this is a test cli', - onAnalyticsEvent: (final event) => events.add(event), + onAnalyticsEvent: (final event, final _) => events.add(event), messageOutput: const MessageOutput(), )..addCommand(MockCommand()); assert(runner.analyticsEnabled()); @@ -275,7 +275,7 @@ void main() { runner = BetterCommandRunner( 'test', 'this is a test cli', - onAnalyticsEvent: (final event) => events.add(event), + onAnalyticsEvent: (final event, final _) => events.add(event), messageOutput: const MessageOutput(), )..addCommand(command); assert(runner.analyticsEnabled()); diff --git a/packages/cli_tools/test/better_command_runner/better_command_test.dart b/packages/cli_tools/test/better_command_runner/better_command_test.dart index d07b23c..745d8f8 100644 --- a/packages/cli_tools/test/better_command_runner/better_command_test.dart +++ b/packages/cli_tools/test/better_command_runner/better_command_test.dart @@ -59,7 +59,7 @@ void main() { final runner = BetterCommandRunner( 'test', 'test project', - onAnalyticsEvent: (final e) => analyticsEvents.add(e), + onAnalyticsEvent: (final e, final _) => analyticsEvents.add(e), messageOutput: messageOutput, )..addCommand(betterCommand); @@ -158,7 +158,7 @@ void main() { final runner = BetterCommandRunner( 'test', 'test project', - onAnalyticsEvent: (final e) => analyticsEvents.add(e), + onAnalyticsEvent: (final e, final _) => analyticsEvents.add(e), globalOptions: [], messageOutput: messageOutput, )..addCommand(betterCommand); diff --git a/packages/cli_tools/test/better_command_runner/default_flags_test.dart b/packages/cli_tools/test/better_command_runner/default_flags_test.dart index 6a8fbcd..a310a8d 100644 --- a/packages/cli_tools/test/better_command_runner/default_flags_test.dart +++ b/packages/cli_tools/test/better_command_runner/default_flags_test.dart @@ -6,7 +6,7 @@ void main() { final runner = BetterCommandRunner( 'test', 'test description', - onAnalyticsEvent: (final event) {}, + onAnalyticsEvent: (final event, final _) {}, messageOutput: const MessageOutput(), ); From 46e6b2b2b2c23fd7e95eacea19035db5419d5665 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 16:03:46 +0100 Subject: [PATCH 02/11] fix: Removes identify method. --- .../lib/src/analytics/analytics.dart | 19 ------------------- .../cli_tools/lib/src/analytics/mixpanel.dart | 8 -------- .../cli_tools/lib/src/analytics/posthog.dart | 18 ------------------ 3 files changed, 45 deletions(-) diff --git a/packages/cli_tools/lib/src/analytics/analytics.dart b/packages/cli_tools/lib/src/analytics/analytics.dart index d59f459..d3367af 100644 --- a/packages/cli_tools/lib/src/analytics/analytics.dart +++ b/packages/cli_tools/lib/src/analytics/analytics.dart @@ -8,12 +8,6 @@ abstract interface class Analytics { required final String event, final Map properties = const {}, }); - - /// Identifies a user with additional properties (e.g., email). - void identify({ - final String? email, - final Map? properties, - }); } class CompoundAnalytics implements Analytics { @@ -40,17 +34,4 @@ class CompoundAnalytics implements Analytics { ); } } - - @override - void identify({ - final String? email, - final Map? properties, - }) { - for (final provider in providers) { - provider.identify( - email: email, - properties: properties, - ); - } - } } diff --git a/packages/cli_tools/lib/src/analytics/mixpanel.dart b/packages/cli_tools/lib/src/analytics/mixpanel.dart index 4a34580..b1040be 100644 --- a/packages/cli_tools/lib/src/analytics/mixpanel.dart +++ b/packages/cli_tools/lib/src/analytics/mixpanel.dart @@ -88,12 +88,4 @@ class MixPanelAnalytics implements Analytics { return; } } - - @override - void identify({ - final String? email, - final Map? properties, - }) { - // Identify events are ignored for MixPanel. - } } diff --git a/packages/cli_tools/lib/src/analytics/posthog.dart b/packages/cli_tools/lib/src/analytics/posthog.dart index b6e782e..869e983 100644 --- a/packages/cli_tools/lib/src/analytics/posthog.dart +++ b/packages/cli_tools/lib/src/analytics/posthog.dart @@ -56,24 +56,6 @@ class PostHogAnalytics implements Analytics { _quietPost(eventData); } - @override - void identify({ - final String? email, - final Map? properties, - }) { - final identifyData = { - 'api_key': _projectApiKey, - 'event': '\$identify', - 'distinct_id': _uniqueUserId, - '\$set': { - if (email != null) 'email': email, - ...?properties, - }, - }; - - _quietPost(identifyData); - } - Future _quietPost(final Map eventData) async { try { await http From fabebf6fa9e90b5512d311232bf84195d4b98ce8 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 16:04:11 +0100 Subject: [PATCH 03/11] chore: Ran dart format. --- .../test/analytics/command_properties_test.dart | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/cli_tools/test/analytics/command_properties_test.dart b/packages/cli_tools/test/analytics/command_properties_test.dart index 6bf383f..e389bb0 100644 --- a/packages/cli_tools/test/analytics/command_properties_test.dart +++ b/packages/cli_tools/test/analytics/command_properties_test.dart @@ -71,12 +71,7 @@ void main() { 'when running with option value ' 'then value is masked in properties and full command', () { - final args = [ - CreateCommand.commandName, - '--mini', - '--name', - 'secret' - ]; + final args = [CreateCommand.commandName, '--mini', '--name', 'secret']; final results = runner.parse(args); final properties = buildCommandPropertiesForAnalytics( @@ -149,13 +144,7 @@ void main() { 'when running with repeated multi options ' 'then each value is masked', () { - final args = [ - CreateCommand.commandName, - '--tag', - 'a', - '--tag', - 'b' - ]; + final args = [CreateCommand.commandName, '--tag', 'a', '--tag', 'b']; final results = runner.parse(args); final properties = buildCommandPropertiesForAnalytics( From 2b2b35ea4057310af994a4e110392e244ea4c130 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 16:04:29 +0100 Subject: [PATCH 04/11] fix: Removes debug print. --- .../lib/src/better_command_runner/better_command_runner.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart index 5bf562e..f0b412e 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart @@ -221,8 +221,6 @@ class BetterCommandRunner final String event, [ final Map properties = const {}, ]) { - print('sendAnalyticsEvent: $event properties: $properties'); - if (analyticsEnabled()) { try { onAnalyticsEvent?.call(event, properties); From 299ef30b25d862fd0050837da6b20dc0580aff55 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 17:11:55 +0100 Subject: [PATCH 05/11] fix: Breaks out internal methods in to private methods. --- .../lib/src/analytics/command_properties.dart | 204 +++++++++++------- 1 file changed, 125 insertions(+), 79 deletions(-) diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart index 5e0e55f..37b18b8 100644 --- a/packages/cli_tools/lib/src/analytics/command_properties.dart +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -9,27 +9,14 @@ Map buildCommandPropertiesForAnalytics({ const maskedValue = 'xxx'; final properties = {}; - // Collect explicitly-provided options/flags and mask any values. - void addOptions(final ArgResults results) { - for (final optionName in results.options) { - if (!results.wasParsed(optionName)) { - continue; - } - final value = results[optionName]; - if (value is bool) { - properties['flag_$optionName'] = value; - } else if (value != null) { - properties['option_$optionName'] = value is List - ? List.filled(value.length, maskedValue) - : maskedValue; - } - } - } - for (ArgResults? current = topLevelResults; current != null; current = current.command) { - addOptions(current); + _addOptions( + results: current, + properties: properties, + maskedValue: maskedValue, + ); } // Reconstruct the command in user input order, masking values. @@ -55,57 +42,14 @@ String _buildFullCommandForAnalytics({ var afterDoubleDash = false; var expectingValue = false; - // Use a consistent placeholder for any sensitive tokens. - void addMasked() { - tokens.add(maskedValue); - } - - String? optionNameForAbbreviation( - final ArgParser parser, - final String abbreviation, - ) { - final option = parser.findByAbbreviation(abbreviation); - if (option == null) { - return null; - } - for (final entry in parser.options.entries) { - if (entry.value == option) { - return entry.key; - } - } - return null; - } - - // Normalizes option tokens and tracks whether a value is expected next. - bool handleOption( - final String name, { - required final bool isNegated, - final bool hasInlineValue = false, - }) { - final option = currentParser.options[name]; - if (option == null) { - addMasked(); - return false; - } - if (option.isFlag) { - tokens.add(isNegated ? '--no-$name' : '--$name'); - return true; - } - tokens.add('--$name'); - if (!hasInlineValue) { - expectingValue = true; - } - return true; - } - for (final arg in arguments) { if (afterDoubleDash) { - addMasked(); + _addMasked(tokens, maskedValue); continue; } if (expectingValue) { - addMasked(); + _addMasked(tokens, maskedValue); expectingValue = false; continue; } @@ -122,19 +66,41 @@ String _buildFullCommandForAnalytics({ final equalIndex = withoutPrefix.indexOf('='); if (equalIndex != -1) { final name = withoutPrefix.substring(0, equalIndex); - if (handleOption(name, isNegated: false, hasInlineValue: true)) { - addMasked(); + if (_handleOption( + name: name, + currentParser: currentParser, + tokens: tokens, + maskedValue: maskedValue, + isNegated: false, + hasInlineValue: true, + expectingValueSetter: (final value) => expectingValue = value, + )) { + _addMasked(tokens, maskedValue); } continue; } if (withoutPrefix.startsWith('no-')) { final name = withoutPrefix.substring(3); - handleOption(name, isNegated: true); + _handleOption( + name: name, + currentParser: currentParser, + tokens: tokens, + maskedValue: maskedValue, + isNegated: true, + expectingValueSetter: (final value) => expectingValue = value, + ); continue; } - handleOption(withoutPrefix, isNegated: false); + _handleOption( + name: withoutPrefix, + currentParser: currentParser, + tokens: tokens, + maskedValue: maskedValue, + isNegated: false, + expectingValueSetter: (final value) => expectingValue = value, + ); continue; } @@ -144,37 +110,52 @@ String _buildFullCommandForAnalytics({ final equalIndex = withoutPrefix.indexOf('='); if (equalIndex != -1) { final abbreviation = withoutPrefix.substring(0, equalIndex); - final name = optionNameForAbbreviation(currentParser, abbreviation); + final name = _optionNameForAbbreviation(currentParser, abbreviation); if (name == null) { - addMasked(); + _addMasked(tokens, maskedValue); continue; } - if (handleOption(name, isNegated: false, hasInlineValue: true)) { - addMasked(); + if (_handleOption( + name: name, + currentParser: currentParser, + tokens: tokens, + maskedValue: maskedValue, + isNegated: false, + hasInlineValue: true, + expectingValueSetter: (final value) => expectingValue = value, + )) { + _addMasked(tokens, maskedValue); } continue; } if (withoutPrefix.length == 1) { - final name = optionNameForAbbreviation(currentParser, withoutPrefix); + final name = _optionNameForAbbreviation(currentParser, withoutPrefix); if (name == null) { - addMasked(); + _addMasked(tokens, maskedValue); continue; } - handleOption(name, isNegated: false); + _handleOption( + name: name, + currentParser: currentParser, + tokens: tokens, + maskedValue: maskedValue, + isNegated: false, + expectingValueSetter: (final value) => expectingValue = value, + ); continue; } for (var i = 0; i < withoutPrefix.length; i++) { final abbreviation = withoutPrefix[i]; - final name = optionNameForAbbreviation(currentParser, abbreviation); + final name = _optionNameForAbbreviation(currentParser, abbreviation); if (name == null) { - addMasked(); + _addMasked(tokens, maskedValue); break; } final option = currentParser.options[name]; if (option == null) { - addMasked(); + _addMasked(tokens, maskedValue); break; } if (option.isFlag) { @@ -183,7 +164,7 @@ String _buildFullCommandForAnalytics({ } tokens.add('--$name'); if (i < withoutPrefix.length - 1) { - addMasked(); + _addMasked(tokens, maskedValue); } else { expectingValue = true; } @@ -200,8 +181,73 @@ String _buildFullCommandForAnalytics({ continue; } - addMasked(); + _addMasked(tokens, maskedValue); } return tokens.join(' '); } + +void _addOptions({ + required final ArgResults results, + required final Map properties, + required final String maskedValue, +}) { + for (final optionName in results.options) { + if (!results.wasParsed(optionName)) { + continue; + } + final value = results[optionName]; + if (value is bool) { + properties['flag_$optionName'] = value; + } else if (value != null) { + properties['option_$optionName'] = value is List + ? List.filled(value.length, maskedValue) + : maskedValue; + } + } +} + +void _addMasked(final List tokens, final String maskedValue) { + tokens.add(maskedValue); +} + +String? _optionNameForAbbreviation( + final ArgParser parser, + final String abbreviation, +) { + final option = parser.findByAbbreviation(abbreviation); + if (option == null) { + return null; + } + for (final entry in parser.options.entries) { + if (entry.value == option) { + return entry.key; + } + } + return null; +} + +bool _handleOption({ + required final String name, + required final ArgParser currentParser, + required final List tokens, + required final String maskedValue, + required final bool isNegated, + required final void Function(bool) expectingValueSetter, + final bool hasInlineValue = false, +}) { + final option = currentParser.options[name]; + if (option == null) { + _addMasked(tokens, maskedValue); + return false; + } + if (option.isFlag) { + tokens.add(isNegated ? '--no-$name' : '--$name'); + return true; + } + tokens.add('--$name'); + if (!hasInlineValue) { + expectingValueSetter(true); + } + return true; +} From ef97b6d749545c0da493a8f031ba920a2f7f1d19 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 17:13:01 +0100 Subject: [PATCH 06/11] fix: Dart format. --- packages/cli_tools/lib/src/analytics/command_properties.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart index 37b18b8..ffd003f 100644 --- a/packages/cli_tools/lib/src/analytics/command_properties.dart +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -200,9 +200,8 @@ void _addOptions({ if (value is bool) { properties['flag_$optionName'] = value; } else if (value != null) { - properties['option_$optionName'] = value is List - ? List.filled(value.length, maskedValue) - : maskedValue; + properties['option_$optionName'] = + value is List ? List.filled(value.length, maskedValue) : maskedValue; } } } From 5b3bdeec6ffd3fca3ac1f7fddea7b11f0af25ae3 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 17:31:18 +0100 Subject: [PATCH 07/11] Revert "fix: Dart format." This reverts commit ef97b6d749545c0da493a8f031ba920a2f7f1d19. --- packages/cli_tools/lib/src/analytics/command_properties.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart index ffd003f..37b18b8 100644 --- a/packages/cli_tools/lib/src/analytics/command_properties.dart +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -200,8 +200,9 @@ void _addOptions({ if (value is bool) { properties['flag_$optionName'] = value; } else if (value != null) { - properties['option_$optionName'] = - value is List ? List.filled(value.length, maskedValue) : maskedValue; + properties['option_$optionName'] = value is List + ? List.filled(value.length, maskedValue) + : maskedValue; } } } From 248aa1f329e29662fa0b1face6d71832fe86f3ed Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 17:31:35 +0100 Subject: [PATCH 08/11] Revert "fix: Breaks out internal methods in to private methods." This reverts commit 299ef30b25d862fd0050837da6b20dc0580aff55. --- .../lib/src/analytics/command_properties.dart | 204 +++++++----------- 1 file changed, 79 insertions(+), 125 deletions(-) diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart index 37b18b8..5e0e55f 100644 --- a/packages/cli_tools/lib/src/analytics/command_properties.dart +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -9,14 +9,27 @@ Map buildCommandPropertiesForAnalytics({ const maskedValue = 'xxx'; final properties = {}; + // Collect explicitly-provided options/flags and mask any values. + void addOptions(final ArgResults results) { + for (final optionName in results.options) { + if (!results.wasParsed(optionName)) { + continue; + } + final value = results[optionName]; + if (value is bool) { + properties['flag_$optionName'] = value; + } else if (value != null) { + properties['option_$optionName'] = value is List + ? List.filled(value.length, maskedValue) + : maskedValue; + } + } + } + for (ArgResults? current = topLevelResults; current != null; current = current.command) { - _addOptions( - results: current, - properties: properties, - maskedValue: maskedValue, - ); + addOptions(current); } // Reconstruct the command in user input order, masking values. @@ -42,14 +55,57 @@ String _buildFullCommandForAnalytics({ var afterDoubleDash = false; var expectingValue = false; + // Use a consistent placeholder for any sensitive tokens. + void addMasked() { + tokens.add(maskedValue); + } + + String? optionNameForAbbreviation( + final ArgParser parser, + final String abbreviation, + ) { + final option = parser.findByAbbreviation(abbreviation); + if (option == null) { + return null; + } + for (final entry in parser.options.entries) { + if (entry.value == option) { + return entry.key; + } + } + return null; + } + + // Normalizes option tokens and tracks whether a value is expected next. + bool handleOption( + final String name, { + required final bool isNegated, + final bool hasInlineValue = false, + }) { + final option = currentParser.options[name]; + if (option == null) { + addMasked(); + return false; + } + if (option.isFlag) { + tokens.add(isNegated ? '--no-$name' : '--$name'); + return true; + } + tokens.add('--$name'); + if (!hasInlineValue) { + expectingValue = true; + } + return true; + } + for (final arg in arguments) { if (afterDoubleDash) { - _addMasked(tokens, maskedValue); + addMasked(); continue; } if (expectingValue) { - _addMasked(tokens, maskedValue); + addMasked(); expectingValue = false; continue; } @@ -66,41 +122,19 @@ String _buildFullCommandForAnalytics({ final equalIndex = withoutPrefix.indexOf('='); if (equalIndex != -1) { final name = withoutPrefix.substring(0, equalIndex); - if (_handleOption( - name: name, - currentParser: currentParser, - tokens: tokens, - maskedValue: maskedValue, - isNegated: false, - hasInlineValue: true, - expectingValueSetter: (final value) => expectingValue = value, - )) { - _addMasked(tokens, maskedValue); + if (handleOption(name, isNegated: false, hasInlineValue: true)) { + addMasked(); } continue; } if (withoutPrefix.startsWith('no-')) { final name = withoutPrefix.substring(3); - _handleOption( - name: name, - currentParser: currentParser, - tokens: tokens, - maskedValue: maskedValue, - isNegated: true, - expectingValueSetter: (final value) => expectingValue = value, - ); + handleOption(name, isNegated: true); continue; } - _handleOption( - name: withoutPrefix, - currentParser: currentParser, - tokens: tokens, - maskedValue: maskedValue, - isNegated: false, - expectingValueSetter: (final value) => expectingValue = value, - ); + handleOption(withoutPrefix, isNegated: false); continue; } @@ -110,52 +144,37 @@ String _buildFullCommandForAnalytics({ final equalIndex = withoutPrefix.indexOf('='); if (equalIndex != -1) { final abbreviation = withoutPrefix.substring(0, equalIndex); - final name = _optionNameForAbbreviation(currentParser, abbreviation); + final name = optionNameForAbbreviation(currentParser, abbreviation); if (name == null) { - _addMasked(tokens, maskedValue); + addMasked(); continue; } - if (_handleOption( - name: name, - currentParser: currentParser, - tokens: tokens, - maskedValue: maskedValue, - isNegated: false, - hasInlineValue: true, - expectingValueSetter: (final value) => expectingValue = value, - )) { - _addMasked(tokens, maskedValue); + if (handleOption(name, isNegated: false, hasInlineValue: true)) { + addMasked(); } continue; } if (withoutPrefix.length == 1) { - final name = _optionNameForAbbreviation(currentParser, withoutPrefix); + final name = optionNameForAbbreviation(currentParser, withoutPrefix); if (name == null) { - _addMasked(tokens, maskedValue); + addMasked(); continue; } - _handleOption( - name: name, - currentParser: currentParser, - tokens: tokens, - maskedValue: maskedValue, - isNegated: false, - expectingValueSetter: (final value) => expectingValue = value, - ); + handleOption(name, isNegated: false); continue; } for (var i = 0; i < withoutPrefix.length; i++) { final abbreviation = withoutPrefix[i]; - final name = _optionNameForAbbreviation(currentParser, abbreviation); + final name = optionNameForAbbreviation(currentParser, abbreviation); if (name == null) { - _addMasked(tokens, maskedValue); + addMasked(); break; } final option = currentParser.options[name]; if (option == null) { - _addMasked(tokens, maskedValue); + addMasked(); break; } if (option.isFlag) { @@ -164,7 +183,7 @@ String _buildFullCommandForAnalytics({ } tokens.add('--$name'); if (i < withoutPrefix.length - 1) { - _addMasked(tokens, maskedValue); + addMasked(); } else { expectingValue = true; } @@ -181,73 +200,8 @@ String _buildFullCommandForAnalytics({ continue; } - _addMasked(tokens, maskedValue); + addMasked(); } return tokens.join(' '); } - -void _addOptions({ - required final ArgResults results, - required final Map properties, - required final String maskedValue, -}) { - for (final optionName in results.options) { - if (!results.wasParsed(optionName)) { - continue; - } - final value = results[optionName]; - if (value is bool) { - properties['flag_$optionName'] = value; - } else if (value != null) { - properties['option_$optionName'] = value is List - ? List.filled(value.length, maskedValue) - : maskedValue; - } - } -} - -void _addMasked(final List tokens, final String maskedValue) { - tokens.add(maskedValue); -} - -String? _optionNameForAbbreviation( - final ArgParser parser, - final String abbreviation, -) { - final option = parser.findByAbbreviation(abbreviation); - if (option == null) { - return null; - } - for (final entry in parser.options.entries) { - if (entry.value == option) { - return entry.key; - } - } - return null; -} - -bool _handleOption({ - required final String name, - required final ArgParser currentParser, - required final List tokens, - required final String maskedValue, - required final bool isNegated, - required final void Function(bool) expectingValueSetter, - final bool hasInlineValue = false, -}) { - final option = currentParser.options[name]; - if (option == null) { - _addMasked(tokens, maskedValue); - return false; - } - if (option.isFlag) { - tokens.add(isNegated ? '--no-$name' : '--$name'); - return true; - } - tokens.add('--$name'); - if (!hasInlineValue) { - expectingValueSetter(true); - } - return true; -} From 913f8971bdc385aae4a3f25980a302537dce5008 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 17:41:24 +0100 Subject: [PATCH 09/11] refactor: Uses a private class for parsing the command line options. --- .../lib/src/analytics/command_properties.dart | 347 ++++++++++-------- 1 file changed, 190 insertions(+), 157 deletions(-) diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart index 5e0e55f..fadc787 100644 --- a/packages/cli_tools/lib/src/analytics/command_properties.dart +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -6,202 +6,235 @@ Map buildCommandPropertiesForAnalytics({ required final ArgParser argParser, required final Map commands, }) { - const maskedValue = 'xxx'; - final properties = {}; + return _CommandPropertiesBuilder( + topLevelResults: topLevelResults, + argParser: argParser, + commands: commands, + ).build(); +} - // Collect explicitly-provided options/flags and mask any values. - void addOptions(final ArgResults results) { +class _CommandPropertiesBuilder { + _CommandPropertiesBuilder({ + required this.topLevelResults, + required this.argParser, + required this.commands, + }); + + static const _maskedValue = 'xxx'; + + final ArgResults topLevelResults; + final ArgParser argParser; + final Map commands; + final Map _properties = {}; + + late List _tokens; + late ArgParser _currentParser; + late Map _currentCommands; + var _afterDoubleDash = false; + var _expectingValue = false; + + Map build() { + _collectOptions(); + _properties['full_command'] = _buildFullCommand(); + return _properties; + } + + void _collectOptions() { + for (ArgResults? current = topLevelResults; + current != null; + current = current.command) { + _addOptions(current); + } + } + + void _addOptions(final ArgResults results) { for (final optionName in results.options) { if (!results.wasParsed(optionName)) { continue; } final value = results[optionName]; if (value is bool) { - properties['flag_$optionName'] = value; + _properties['flag_$optionName'] = value; } else if (value != null) { - properties['option_$optionName'] = value is List - ? List.filled(value.length, maskedValue) - : maskedValue; + _properties['option_$optionName'] = value is List + ? List.filled(value.length, _maskedValue) + : _maskedValue; } } } - for (ArgResults? current = topLevelResults; - current != null; - current = current.command) { - addOptions(current); - } + String _buildFullCommand() { + _resetCommandState(); - // Reconstruct the command in user input order, masking values. - properties['full_command'] = _buildFullCommandForAnalytics( - arguments: topLevelResults.arguments, - maskedValue: maskedValue, - argParser: argParser, - commands: commands, - ); + for (final arg in topLevelResults.arguments) { + if (_afterDoubleDash) { + _addMasked(); + continue; + } - return properties; -} + if (_expectingValue) { + _addMasked(); + _expectingValue = false; + continue; + } -String _buildFullCommandForAnalytics({ - required final List arguments, - required final String maskedValue, - required final ArgParser argParser, - required final Map commands, -}) { - final tokens = []; - var currentParser = argParser; - var currentCommands = commands; - var afterDoubleDash = false; - var expectingValue = false; - - // Use a consistent placeholder for any sensitive tokens. - void addMasked() { - tokens.add(maskedValue); - } + if (arg == '--') { + _afterDoubleDash = true; + _tokens.add('--'); + continue; + } - String? optionNameForAbbreviation( - final ArgParser parser, - final String abbreviation, - ) { - final option = parser.findByAbbreviation(abbreviation); - if (option == null) { - return null; - } - for (final entry in parser.options.entries) { - if (entry.value == option) { - return entry.key; + if (arg.startsWith('--')) { + _handleLongOption(arg); + continue; } + + if (arg.startsWith('-') && arg != '-') { + _handleShortOption(arg); + continue; + } + + final command = _currentCommands[arg]; + if (command != null) { + _tokens.add(arg); + _currentParser = command.argParser; + _currentCommands = command.subcommands; + continue; + } + + _addMasked(); } - return null; + + return _tokens.join(' '); } - // Normalizes option tokens and tracks whether a value is expected next. - bool handleOption( - final String name, { - required final bool isNegated, - final bool hasInlineValue = false, - }) { - final option = currentParser.options[name]; - if (option == null) { - addMasked(); - return false; - } - if (option.isFlag) { - tokens.add(isNegated ? '--no-$name' : '--$name'); - return true; - } - tokens.add('--$name'); - if (!hasInlineValue) { - expectingValue = true; - } - return true; + void _resetCommandState() { + _tokens = []; + _currentParser = argParser; + _currentCommands = commands; + _afterDoubleDash = false; + _expectingValue = false; } - for (final arg in arguments) { - if (afterDoubleDash) { - addMasked(); - continue; + void _handleLongOption(final String arg) { + // Long options; normalize and mask any provided value. + final withoutPrefix = arg.substring(2); + final equalIndex = withoutPrefix.indexOf('='); + if (equalIndex != -1) { + final name = withoutPrefix.substring(0, equalIndex); + final handled = _handleOption( + name: name, + isNegated: false, + hasInlineValue: true, + ); + if (handled) { + _addMasked(); + } + return; } - if (expectingValue) { - addMasked(); - expectingValue = false; - continue; + if (withoutPrefix.startsWith('no-')) { + final name = withoutPrefix.substring(3); + _handleOption(name: name, isNegated: true); + return; } - if (arg == '--') { - afterDoubleDash = true; - tokens.add('--'); - continue; - } + _handleOption(name: withoutPrefix, isNegated: false); + } - if (arg.startsWith('--')) { - // Long options; normalize and mask any provided value. - final withoutPrefix = arg.substring(2); - final equalIndex = withoutPrefix.indexOf('='); - if (equalIndex != -1) { - final name = withoutPrefix.substring(0, equalIndex); - if (handleOption(name, isNegated: false, hasInlineValue: true)) { - addMasked(); - } - continue; + void _handleShortOption(final String arg) { + // Short options; expand to their long form when possible. + final withoutPrefix = arg.substring(1); + final equalIndex = withoutPrefix.indexOf('='); + if (equalIndex != -1) { + final abbreviation = withoutPrefix.substring(0, equalIndex); + final name = _optionNameForAbbreviation(abbreviation); + if (name == null) { + _addMasked(); + return; } - - if (withoutPrefix.startsWith('no-')) { - final name = withoutPrefix.substring(3); - handleOption(name, isNegated: true); - continue; + final handled = _handleOption( + name: name, + isNegated: false, + hasInlineValue: true, + ); + if (handled) { + _addMasked(); } - - handleOption(withoutPrefix, isNegated: false); - continue; + return; } - if (arg.startsWith('-') && arg != '-') { - // Short options; expand to their long form when possible. - final withoutPrefix = arg.substring(1); - final equalIndex = withoutPrefix.indexOf('='); - if (equalIndex != -1) { - final abbreviation = withoutPrefix.substring(0, equalIndex); - final name = optionNameForAbbreviation(currentParser, abbreviation); - if (name == null) { - addMasked(); - continue; - } - if (handleOption(name, isNegated: false, hasInlineValue: true)) { - addMasked(); - } - continue; + if (withoutPrefix.length == 1) { + final name = _optionNameForAbbreviation(withoutPrefix); + if (name == null) { + _addMasked(); + return; } + _handleOption(name: name, isNegated: false); + return; + } - if (withoutPrefix.length == 1) { - final name = optionNameForAbbreviation(currentParser, withoutPrefix); - if (name == null) { - addMasked(); - continue; - } - handleOption(name, isNegated: false); - continue; + for (var i = 0; i < withoutPrefix.length; i++) { + final abbreviation = withoutPrefix[i]; + final name = _optionNameForAbbreviation(abbreviation); + if (name == null) { + _addMasked(); + break; } - - for (var i = 0; i < withoutPrefix.length; i++) { - final abbreviation = withoutPrefix[i]; - final name = optionNameForAbbreviation(currentParser, abbreviation); - if (name == null) { - addMasked(); - break; - } - final option = currentParser.options[name]; - if (option == null) { - addMasked(); - break; - } - if (option.isFlag) { - tokens.add('--$name'); - continue; - } - tokens.add('--$name'); - if (i < withoutPrefix.length - 1) { - addMasked(); - } else { - expectingValue = true; - } + final option = _currentParser.options[name]; + if (option == null) { + _addMasked(); break; } - continue; + if (option.isFlag) { + _tokens.add('--$name'); + continue; + } + _tokens.add('--$name'); + if (i < withoutPrefix.length - 1) { + _addMasked(); + } else { + _expectingValue = true; + } + break; } + } - final command = currentCommands[arg]; - if (command != null) { - tokens.add(arg); - currentParser = command.argParser; - currentCommands = command.subcommands; - continue; - } + void _addMasked() { + _tokens.add(_maskedValue); + } - addMasked(); + String? _optionNameForAbbreviation(final String abbreviation) { + final option = _currentParser.findByAbbreviation(abbreviation); + if (option == null) { + return null; + } + for (final entry in _currentParser.options.entries) { + if (entry.value == option) { + return entry.key; + } + } + return null; } - return tokens.join(' '); + bool _handleOption({ + required final String name, + required final bool isNegated, + final bool hasInlineValue = false, + }) { + final option = _currentParser.options[name]; + if (option == null) { + _addMasked(); + _expectingValue = false; + return false; + } + if (option.isFlag) { + _tokens.add(isNegated ? '--no-$name' : '--$name'); + _expectingValue = false; + return true; + } + _tokens.add('--$name'); + _expectingValue = !hasInlineValue; + return true; + } } From 5d1e862a8f13af62ab4ce1c780cdda23a27e27ce Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 17:47:21 +0100 Subject: [PATCH 10/11] refactor: Simplifies the code. --- .../lib/src/analytics/command_properties.dart | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart index fadc787..16bb1ce 100644 --- a/packages/cli_tools/lib/src/analytics/command_properties.dart +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -27,9 +27,9 @@ class _CommandPropertiesBuilder { final Map commands; final Map _properties = {}; - late List _tokens; - late ArgParser _currentParser; - late Map _currentCommands; + final List _tokens = []; + late ArgParser _currentParser = argParser; + late Map _currentCommands = commands; var _afterDoubleDash = false; var _expectingValue = false; @@ -64,8 +64,6 @@ class _CommandPropertiesBuilder { } String _buildFullCommand() { - _resetCommandState(); - for (final arg in topLevelResults.arguments) { if (_afterDoubleDash) { _addMasked(); @@ -108,14 +106,6 @@ class _CommandPropertiesBuilder { return _tokens.join(' '); } - void _resetCommandState() { - _tokens = []; - _currentParser = argParser; - _currentCommands = commands; - _afterDoubleDash = false; - _expectingValue = false; - } - void _handleLongOption(final String arg) { // Long options; normalize and mask any provided value. final withoutPrefix = arg.substring(2); From 73c90b6f5d47440478ca80b26e45a17364ea6eee Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 28 Jan 2026 17:49:44 +0100 Subject: [PATCH 11/11] refactor: Breaks duplicated code out into a new method. --- .../lib/src/analytics/command_properties.dart | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart index 16bb1ce..b80e8df 100644 --- a/packages/cli_tools/lib/src/analytics/command_properties.dart +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -111,15 +111,10 @@ class _CommandPropertiesBuilder { final withoutPrefix = arg.substring(2); final equalIndex = withoutPrefix.indexOf('='); if (equalIndex != -1) { - final name = withoutPrefix.substring(0, equalIndex); - final handled = _handleOption( - name: name, + _handleInlineOption( + name: withoutPrefix.substring(0, equalIndex), isNegated: false, - hasInlineValue: true, ); - if (handled) { - _addMasked(); - } return; } @@ -143,14 +138,7 @@ class _CommandPropertiesBuilder { _addMasked(); return; } - final handled = _handleOption( - name: name, - isNegated: false, - hasInlineValue: true, - ); - if (handled) { - _addMasked(); - } + _handleInlineOption(name: name, isNegated: false); return; } @@ -194,6 +182,20 @@ class _CommandPropertiesBuilder { _tokens.add(_maskedValue); } + void _handleInlineOption({ + required final String name, + required final bool isNegated, + }) { + final handled = _handleOption( + name: name, + isNegated: isNegated, + hasInlineValue: true, + ); + if (handled) { + _addMasked(); + } + } + String? _optionNameForAbbreviation(final String abbreviation) { final option = _currentParser.findByAbbreviation(abbreviation); if (option == null) {