Line data Source code
1 : import 'dart:async';
2 : import 'dart:io';
3 :
4 : import 'package:args/args.dart';
5 : import 'package:args/command_runner.dart';
6 : import 'package:mason/mason.dart';
7 : import 'package:meta/meta.dart';
8 : import 'package:path/path.dart' as path;
9 : import 'package:very_good_cli/src/commands/commands.dart';
10 : import 'package:very_good_cli/src/commands/create/templates/templates.dart';
11 :
12 : // A valid Dart identifier that can be used for a package, i.e. no
13 : // capital letters.
14 : // https://dart.dev/guides/language/language-tour#important-concepts
15 3 : final RegExp _identifierRegExp = RegExp('[a-z_][a-z0-9_]*');
16 3 : final RegExp _orgNameRegExp = RegExp(r'^[a-zA-Z][\w-]*(\.[a-zA-Z][\w-]*)+$');
17 :
18 : const _defaultOrgName = 'com.example.verygoodcore';
19 : const _defaultDescription = 'A Very Good Project created by Very Good CLI.';
20 :
21 : /// A method which returns a [Future<MasonGenerator>] given a [MasonBundle].
22 : typedef MasonGeneratorFromBundle = Future<MasonGenerator> Function(MasonBundle);
23 :
24 : /// A method which returns a [Future<MasonGenerator>] given a [Brick].
25 : typedef MasonGeneratorFromBrick = Future<MasonGenerator> Function(Brick);
26 :
27 : /// {@template create_subcommand}
28 : /// Generic class for sub commands of [CreateCommand].
29 : /// {@endtemplate}
30 : ///
31 : /// It contains the common logic for all sub commands of [CreateCommand],
32 : /// including the [run] and [runCreate] routines.
33 : ///
34 : /// By default, adds the following arguments to the [argParser]:
35 : /// - 'output-directory': the output directory
36 : /// - 'description': the description of the project
37 : ///
38 : /// Sub classes must implement [name], [description] and [template].
39 : ///
40 : /// For sub commands with multiple templates, sub classes must mix with
41 : /// [MultiTemplates].
42 : ///
43 : /// For sub commands that receive an org name, sub classes must mix with
44 : /// [OrgName].
45 : ///
46 : /// For sub commands that receive a publishable flag, sub classes must mix with
47 : /// [Publishable].
48 : abstract class CreateSubCommand extends Command<int> {
49 : /// {@macro create_subcommand}
50 1 : CreateSubCommand({
51 : required this.logger,
52 : @visibleForTesting required MasonGeneratorFromBundle? generatorFromBundle,
53 : @visibleForTesting required MasonGeneratorFromBrick? generatorFromBrick,
54 : }) : _generatorFromBundle = generatorFromBundle ?? MasonGenerator.fromBundle,
55 : _generatorFromBrick = generatorFromBrick ?? MasonGenerator.fromBrick {
56 1 : argParser
57 1 : ..addOption(
58 : 'output-directory',
59 : abbr: 'o',
60 : help: 'The desired output directory when creating a new project.',
61 : )
62 1 : ..addOption(
63 : 'description',
64 : help: 'The description for this new project.',
65 1 : aliases: ['desc'],
66 : defaultsTo: _defaultDescription,
67 : );
68 :
69 : // Add the templates arg if the command has multiple templates.
70 1 : if (this is MultiTemplates) {
71 : final multiTemplates = this as MultiTemplates;
72 1 : final defaultTemplateName = multiTemplates.defaultTemplateName;
73 1 : final templates = multiTemplates.templates;
74 :
75 2 : argParser.addOption(
76 : 'template',
77 : abbr: 't',
78 : help: 'The template used to generate this new project.',
79 : defaultsTo: defaultTemplateName,
80 4 : allowed: templates.map((element) => element.name).toList(),
81 1 : allowedHelp: templates.fold<Map<String, String>>(
82 1 : {},
83 2 : (previousValue, element) => {
84 : ...previousValue,
85 3 : element.name: element.help,
86 : },
87 : ),
88 : );
89 : }
90 :
91 1 : if (this is OrgName) {
92 2 : argParser.addOption(
93 : 'org-name',
94 : help: 'The organization for this new project.',
95 : defaultsTo: _defaultOrgName,
96 1 : aliases: ['org'],
97 : );
98 : }
99 :
100 1 : if (this is Publishable) {
101 2 : argParser.addFlag(
102 : 'publishable',
103 : negatable: false,
104 : help: 'Whether the generated project is intended to be published.',
105 : );
106 : }
107 : }
108 :
109 : /// The logger user to notify the user of the command's progress.
110 : final Logger logger;
111 : final MasonGeneratorFromBundle _generatorFromBundle;
112 : final MasonGeneratorFromBrick _generatorFromBrick;
113 :
114 : /// [ArgResults] which can be overridden for testing.
115 : @visibleForTesting
116 : ArgResults? argResultOverrides;
117 :
118 : /// Gets the output [Directory].
119 1 : Directory get outputDirectory {
120 2 : final directory = argResults['output-directory'] as String? ?? '.';
121 1 : return Directory(directory);
122 : }
123 :
124 : /// Gets the project name.
125 1 : String get projectName {
126 2 : final args = argResults.rest;
127 1 : _validateProjectName(args);
128 1 : return args.first;
129 : }
130 :
131 : /// Gets the description for the project.
132 3 : String get projectDescription => argResults['description'] as String? ?? '';
133 :
134 : /// Should return the desired template to be created during a command run.
135 : ///
136 : /// For sub commands with multiple templates, see [MultiTemplates].
137 : Template get template;
138 :
139 1 : @override
140 2 : String get invocation => 'very_good create $name <project-name> [arguments]';
141 :
142 1 : @override
143 2 : ArgResults get argResults => argResultOverrides ?? super.argResults!;
144 :
145 1 : bool _isValidPackageName(String name) {
146 2 : final match = _identifierRegExp.matchAsPrefix(name);
147 3 : return match != null && match.end == name.length;
148 : }
149 :
150 1 : void _validateProjectName(List<String> args) {
151 3 : logger.detail('Validating project name; args: $args');
152 :
153 1 : if (args.isEmpty) {
154 1 : usageException('No option specified for the project name.');
155 : }
156 :
157 2 : if (args.length > 1) {
158 1 : usageException('Multiple project names specified.');
159 : }
160 :
161 1 : final name = args.first;
162 1 : final isValidProjectName = _isValidPackageName(name);
163 : if (!isValidProjectName) {
164 2 : usageException(
165 : '"$name" is not a valid package name.\n\n'
166 : 'See https://dart.dev/tools/pub/pubspec#name for more information.',
167 : );
168 : }
169 : }
170 :
171 1 : Future<MasonGenerator> _getGeneratorForTemplate() async {
172 : try {
173 1 : final brick = Brick.version(
174 3 : name: template.bundle.name,
175 4 : version: '^${template.bundle.version}',
176 : );
177 2 : logger.detail(
178 4 : '''Building generator from brick: ${brick.name} ${brick.location.version}''',
179 : );
180 2 : return await _generatorFromBrick(brick);
181 : } catch (_) {
182 3 : logger.detail('Building generator from brick failed: $_');
183 : }
184 2 : logger.detail(
185 7 : '''Building generator from bundle ${template.bundle.name} ${template.bundle.version}''',
186 : );
187 4 : return _generatorFromBundle(template.bundle);
188 : }
189 :
190 1 : @override
191 : Future<int> run() async {
192 1 : final template = this.template;
193 1 : final generator = await _getGeneratorForTemplate();
194 1 : final result = await runCreate(generator, template);
195 :
196 : return result;
197 : }
198 :
199 : /// Invoked by [run] to create the project, contains the logic for using
200 : /// the template vars obtained by [getTemplateVars] to generate the project
201 : /// from the [generator] and [template].
202 1 : Future<int> runCreate(MasonGenerator generator, Template template) async {
203 1 : var vars = getTemplateVars();
204 :
205 2 : final generateProgress = logger.progress('Bootstrapping');
206 2 : final target = DirectoryGeneratorTarget(outputDirectory);
207 :
208 2 : await generator.hooks.preGen(vars: vars, onVarsChanged: (v) => vars = v);
209 2 : final files = await generator.generate(target, vars: vars, logger: logger);
210 3 : generateProgress.complete('Generated ${files.length} file(s)');
211 :
212 1 : await template.onGenerateComplete(
213 1 : logger,
214 5 : Directory(path.join(target.dir.path, projectName)),
215 : );
216 :
217 1 : return ExitCode.success.code;
218 : }
219 :
220 : /// Responsible for returns the template parameters to be passed to the
221 : /// template brick.
222 : ///
223 : /// Override if the create sub command requires additional template
224 : /// parameters.
225 : ///
226 : /// For subcommands that mix with [OrgName], it includes 'org_name'.
227 : /// For subcommands that mix with [Publishable], it includes 'publishable'.
228 1 : @mustCallSuper
229 : Map<String, dynamic> getTemplateVars() {
230 1 : final projectName = this.projectName;
231 1 : final projectDescription = this.projectDescription;
232 :
233 1 : return <String, dynamic>{
234 1 : 'project_name': projectName,
235 1 : 'description': projectDescription,
236 3 : if (this is OrgName) 'org_name': (this as OrgName).orgName,
237 3 : if (this is Publishable) 'publishable': (this as Publishable).publishable,
238 : };
239 : }
240 : }
241 :
242 : /// Mixin for [CreateSubCommand] subclasses that receives the org name
243 : /// parameter.
244 : ///
245 : /// Takes care of parsing from [argResults] and validating the org name.
246 : mixin OrgName on CreateSubCommand {
247 : /// Gets the organization name.
248 4 : String get orgName {
249 8 : final orgName = argResults['org-name'] as String? ?? _defaultOrgName;
250 4 : _validateOrgName(orgName);
251 : return orgName;
252 : }
253 :
254 4 : void _validateOrgName(String name) {
255 12 : logger.detail('Validating org name; $name');
256 4 : final isValidOrgName = _isValidOrgName(name);
257 : if (!isValidOrgName) {
258 2 : usageException(
259 : '"$name" is not a valid org name.\n\n'
260 : 'A valid org name has at least 2 parts separated by "."\n'
261 : 'Each part must start with a letter and only include '
262 : 'alphanumeric characters (A-Z, a-z, 0-9), underscores (_), '
263 : 'and hyphens (-)\n'
264 : '(ex. very.good.org)',
265 : );
266 : }
267 : }
268 :
269 4 : bool _isValidOrgName(String name) {
270 8 : return _orgNameRegExp.hasMatch(name);
271 : }
272 : }
273 :
274 : /// Mixin for [CreateSubCommand] subclasses that receives multiple templates.
275 : ///
276 : /// Subcommands that mix with this mixin should override [templates].
277 : ///
278 : /// Takes care of parsing the desired template from [argResults] and
279 : /// validating the org name.
280 : mixin MultiTemplates on CreateSubCommand {
281 : /// Gets the desired template to be created during a command run when the
282 : /// template argument is not provided.
283 : ///
284 : /// Defaults to the first template in [templates].
285 8 : String get defaultTemplateName => templates.first.name;
286 :
287 : /// Gets all the templates to be created during a command run.
288 : List<Template> get templates;
289 :
290 2 : @nonVirtual
291 : @override
292 : Template get template {
293 : final templateName =
294 4 : argResults['template'] as String? ?? defaultTemplateName;
295 :
296 4 : return templates.firstWhere(
297 6 : (element) => element.name == templateName,
298 : );
299 : }
300 : }
301 :
302 : /// Mixin for [CreateSubCommand] subclasses that receives the publishable
303 : /// flag.
304 : ///
305 : /// Takes care of parsing it from [argResults] and pass it
306 : /// to the brick generator.
307 : mixin Publishable on CreateSubCommand {
308 : /// Gets the publishable flag.
309 15 : bool get publishable => argResults['publishable'] as bool? ?? false;
310 : }
|