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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ very_good packages check licenses --forbidden="unknown"

# Check licenses for certain dependencies types
very_good packages check licenses --dependency-type="direct-main,transitive"

# Check and list licenses in the current directory
very_good packages check licenses --reporter="csv"
```

### [`very_good mcp`](https://cli.vgv.dev/docs/commands/mcp)
Expand All @@ -192,6 +195,7 @@ very_good mcp
The MCP server exposes Very Good CLI functionality through the Model Context Protocol, allowing AI assistants to interact with the CLI programmatically. This enables automated project creation, testing, and package management through MCP-compatible tools.

**Available MCP Tools:**

- `create`: Create new Dart/Flutter projects (https://cli.vgv.dev/docs/category/templates)
- `tests`: Run tests with optional coverage and optimization (https://cli.vgv.dev/docs/commands/test)
- `packages_check_licenses`: Check packages for issues and licenses (https://cli.vgv.dev/docs/commands/check_licenses)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ class PackagesCheckLicensesCommand extends Command<int> {
..addMultiOption(
'skip-packages',
help: 'Skip packages from having their licenses checked.',
)
..addOption(
'reporter',
help: 'Lists all licenses.',
allowed: [
'text',
'csv',
],
allowedHelp: {
'text': 'Lists licenses without a specific format.',
'csv': 'Lists licenses in a CSV format.',
},
);
}

Expand All @@ -128,6 +140,11 @@ class PackagesCheckLicensesCommand extends Command<int> {
final allowedLicenses = _argResults['allowed'] as List<String>;
final forbiddenLicenses = _argResults['forbidden'] as List<String>;
final skippedPackages = _argResults['skip-packages'] as List<String>;
final reporterOutput = _argResults['reporter'] as String?;

final reporterOutputFormat = ReporterOutputFormat.fromString(
reporterOutput,
);

allowedLicenses.removeWhere((license) => license.trim().isEmpty);
forbiddenLicenses.removeWhere((license) => license.trim().isEmpty);
Expand Down Expand Up @@ -312,6 +329,7 @@ class PackagesCheckLicensesCommand extends Command<int> {
_composeReport(
licenses: licenses,
bannedDependencies: bannedDependencies,
reporterOutputFormat: reporterOutputFormat,
),
);

Expand Down Expand Up @@ -409,6 +427,7 @@ _BannedDependencyLicenseMap? _bannedDependencies({
String _composeReport({
required _DependencyLicenseMap licenses,
required _BannedDependencyLicenseMap? bannedDependencies,
ReporterOutputFormat? reporterOutputFormat,
}) {
final bannedLicenseTypes = bannedDependencies?.values.fold(<String>{}, (
previousValue,
Expand Down Expand Up @@ -453,7 +472,24 @@ String _composeReport({
? ''
: ' of type: ${formattedLicenseTypes.toList().stringify()}';

return '''Retrieved $totalLicenseCount $licenseWord from ${licenses.length} $packageWord$suffix.''';
final licenseBuilder = StringBuffer();
if (reporterOutputFormat case final ReporterOutputFormat outputFormat) {
licenseBuilder.write('\n');
for (final license in licenses.entries) {
if (license.value case final Set<String> dependencyLicenses) {
for (final dependencyLicense in dependencyLicenses) {
licenseBuilder.writeln(
outputFormat.formatLicense(
packageName: license.key,
licenseName: dependencyLicense,
),
);
}
}
}
}

return '''Retrieved $totalLicenseCount $licenseWord from ${licenses.length} $packageWord$suffix.$licenseBuilder''';
}

String _composeBannedReport(_BannedDependencyLicenseMap bannedDependencies) {
Expand Down Expand Up @@ -497,3 +533,40 @@ extension on List<Object> {
return '${join(', ')} and $last';
}
}

/// Format type for listing all licenses via --reporter option.
enum ReporterOutputFormat {
/// List all licenses separated by a dash.
///
/// Example: very_good_cli - MIT
text,

/// List all licenses in a CSV format.
///
/// Example: very_good_cli,MIT
csv
;

/// Convenience parsing method from user input.
///
/// Return desired format for valid inputs
/// null for invalid inputs or unspecified input.
static ReporterOutputFormat? fromString(String? value) {
return switch (value) {
'text' => text,
'csv' => csv,
_ => null,
};
}

/// Stringify the package with it's license into the desired format
String formatLicense({
required String packageName,
required String licenseName,
}) {
return switch (this) {
text => '$packageName - $licenseName',
csv => '$packageName,$licenseName',
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ const _expectedPackagesCheckLicensesUsage = [
' --allowed Only allow the use of certain licenses.\n'
' --forbidden Deny the use of certain licenses.\n'
' --skip-packages Skip packages from having their licenses checked.\n'
' --reporter Lists all licenses.\n'
'\n'
' [text] Lists licenses without a specific format.\n'
' [csv] Lists licenses in a CSV format.\n'
'\n'
'Run "very_good help" to see global options.',
];
Expand Down Expand Up @@ -1457,6 +1461,105 @@ and limitations under the License.''');
});
});

group('reporter', () {
const reporterArgument = '--reporter';

test(
'when no option is provided',
withRunner((commandRunner, logger, pubUpdater, printLogs) async {
final result = await commandRunner.run(
[...commandArguments, reporterArgument],
);
expect(result, equals(ExitCode.usage.code));
}),
);

test(
'when invalid option is provided',
withRunner((commandRunner, logger, pubUpdater, printLogs) async {
final result = await commandRunner.run(
[...commandArguments, reporterArgument, 'invalid'],
);
expect(result, equals(ExitCode.usage.code));
}),
);
test(
'text format prints packages with license separated by a dash',
withRunner((commandRunner, logger, pubUpdater, printLogs) async {
File(
path.join(tempDirectory.path, pubspecLockBasename),
).writeAsStringSync(_validPubspecLockContent);

when(() => packageConfig.packages).thenReturn({
veryGoodTestRunnerConfigPackage,
});

when(() => detectorResult.matches).thenReturn([mitLicenseMatch]);
when(() => logger.progress(any())).thenReturn(progress);

final result = await commandRunner.run(
[
...commandArguments,
reporterArgument,
'text',
tempDirectory.path,
],
);

const expectedOutput =
'''Retrieved 1 license from 1 package of type: MIT (1).\nvery_good_test_runner - MIT\n''';

verify(
() => progress.update(
'Collecting licenses from 1 out of 1 package',
),
).called(1);
verify(
() => progress.complete(expectedOutput),
).called(1);

expect(result, equals(ExitCode.success.code));
}),
);
test(
'csv format prints packages with license in a CSV format.',
withRunner((commandRunner, logger, pubUpdater, printLogs) async {
File(
path.join(tempDirectory.path, pubspecLockBasename),
).writeAsStringSync(_validPubspecLockContent);

when(() => packageConfig.packages).thenReturn({
veryGoodTestRunnerConfigPackage,
});
when(() => detectorResult.matches).thenReturn([mitLicenseMatch]);
when(() => logger.progress(any())).thenReturn(progress);

final result = await commandRunner.run(
[
...commandArguments,
reporterArgument,
'csv',
tempDirectory.path,
],
);

const expectedOutput =
'''Retrieved 1 license from 1 package of type: MIT (1).\nvery_good_test_runner,MIT\n''';

verify(
() => progress.update(
'Collecting licenses from 1 out of 1 package',
),
).called(1);
verify(
() => progress.complete(expectedOutput),
).called(1);

expect(result, equals(ExitCode.success.code));
}),
);
});

group('skip-packages', () {
const skipPackagesArgument = '--skip-packages';

Expand Down