Commands
Introduction
Commands let you extend Nylo Website's CLI with custom project-specific tooling. By subclassing NyCustomCommand, you can automate repetitive tasks, build deployment workflows, generate code, or add any functionality you need directly in your terminal.
Every custom command has access to a rich set of built-in helpers for file I/O, JSON/YAML, interactive prompts, spinners, progress bars, API requests, and more -- all without importing extra packages.
Note: Custom commands run outside the Flutter runtime. You cannot import
nylo_framework.dartin your commands. Useny_cli.dartinstead.
Creating Commands
Create a new command using Metro or the Dart CLI:
metro make:command current_time
You can specify a category for your command using the --category option:
metro make:command current_time --category="project"
This creates a new file at lib/app/commands/current_time.dart and registers it in the command registry.
Command Structure
Every command extends NyCustomCommand and implements two key methods:
builder()-- configure options and flagshandle()-- execute the command logic
import 'package:nylo_framework/metro/ny_cli.dart';
void main(arguments) => _CurrentTimeCommand(arguments).run();
/// Current Time Command
///
/// Usage:
/// metro app:current_time
class _CurrentTimeCommand extends NyCustomCommand {
_CurrentTimeCommand(super.arguments);
@override
CommandBuilder builder(CommandBuilder command) {
command.addOption('format', defaultValue: 'HH:mm:ss');
return command;
}
@override
Future<void> handle(CommandResult result) async {
final format = result.getString("format");
info("Current time format: $format");
}
}
Running Commands
Run your command using Metro:
metro app:current_time
The command name follows the pattern category:name. When you run metro without arguments, your custom commands appear under the Custom Commands section:
[Custom Commands]
app:current_time
project:install_firebase
project:deploy
To display help for a command:
metro app:current_time --help
Command Registry
All custom commands are registered in lib/app/commands/commands.json. This file is updated automatically when you use make:command:
[
{
"name": "install_firebase",
"category": "project",
"script": "install_firebase.dart"
},
{
"name": "current_time",
"category": "app",
"script": "current_time.dart"
}
]
Each entry has:
| Field | Description |
|---|---|
name |
The command name (used after the category prefix) |
category |
The command category (e.g. app, project) |
script |
The Dart file in lib/app/commands/ |
Options and Flags
Configure your command's options and flags in the builder() method using CommandBuilder.
Adding Options
Options accept a value from the user:
@override
CommandBuilder builder(CommandBuilder command) {
command.addOption(
'environment',
abbr: 'e',
help: 'Target deployment environment',
defaultValue: 'development',
allowed: ['development', 'staging', 'production'],
);
return command;
}
Usage:
metro project:deploy --environment=production
# or using abbreviation
metro project:deploy -e production
| Parameter | Type | Description |
|---|---|---|
name |
String |
Option name |
abbr |
String? |
Single-character abbreviation |
help |
String? |
Help text shown with --help |
allowed |
List<String>? |
Restrict to allowed values |
defaultValue |
String? |
Default if not provided |
Adding Flags
Flags are boolean toggles:
@override
CommandBuilder builder(CommandBuilder command) {
command.addFlag(
'verbose',
abbr: 'v',
help: 'Enable verbose output',
defaultValue: false,
);
return command;
}
Usage:
metro project:deploy --verbose
metro project:deploy -v
| Parameter | Type | Description |
|---|---|---|
name |
String |
Flag name |
abbr |
String? |
Single-character abbreviation |
help |
String? |
Help text shown with --help |
defaultValue |
bool |
Default value (default: false) |
Command Result
The handle() method receives a CommandResult object with typed accessors for reading parsed options, flags, and arguments.
@override
Future<void> handle(CommandResult result) async {
// Get a string option
final name = result.getString('name');
// Get a boolean flag
final verbose = result.getBool('verbose');
// Get an integer option
final count = result.getInt('count');
// Generic typed access
final value = result.get<String>('key');
// Built-in flag checks
if (result.hasForceFlag) { /* --force was passed */ }
if (result.hasHelpFlag) { /* --help was passed */ }
// Raw arguments
List<String> allArgs = result.arguments;
List<String> unparsed = result.rest;
}
| Method / Property | Returns | Description |
|---|---|---|
getString(String name, {String? defaultValue}) |
String? |
Get string value |
getBool(String name, {bool? defaultValue}) |
bool? |
Get boolean value |
getInt(String name, {int? defaultValue}) |
int? |
Get integer value |
get<T>(String name) |
T? |
Get typed value |
hasForceFlag |
bool |
Whether --force was passed |
hasHelpFlag |
bool |
Whether --help was passed |
arguments |
List<String> |
All command-line arguments |
rest |
List<String> |
Unparsed rest arguments |
Interactive Input
NyCustomCommand provides methods for collecting user input in the terminal.
Text Input
// Ask a question with optional default
final name = prompt('What is your project name?', defaultValue: 'my_app');
// ask() is an alias for prompt()
final description = ask('Enter a description:');
| Parameter | Type | Description |
|---|---|---|
question |
String |
The question to display |
defaultValue |
String |
Default if user presses Enter (default: '') |
Confirmation
if (confirm('Would you like to continue?', defaultValue: true)) {
await runProcess('flutter pub get');
} else {
info('Operation canceled');
}
| Parameter | Type | Description |
|---|---|---|
question |
String |
The yes/no question |
defaultValue |
bool |
Default answer (default: false) |
Single Selection
final environment = select(
'Select deployment environment:',
['development', 'staging', 'production'],
defaultOption: 'development',
);
| Parameter | Type | Description |
|---|---|---|
question |
String |
The prompt text |
options |
List<String> |
Available choices |
defaultOption |
String? |
Pre-selected option |
Multiple Selection
final packages = multiSelect(
'Select packages to install:',
['firebase_auth', 'dio', 'provider', 'shared_preferences'],
);
if (packages.isNotEmpty) {
addPackages(packages);
await runProcess('flutter pub get');
}
The user enters comma-separated numbers or "all".
Secret Input
final apiKey = promptSecret('Enter your API key:');
Input is hidden from the terminal display. Falls back to visible input if echo mode is not supported.
Output Formatting
Methods for printing styled output to the console:
@override
Future<void> handle(CommandResult result) async {
info('Processing files...'); // Blue text
error('Operation failed'); // Red text
success('Deployment complete'); // Green text
warning('Outdated package'); // Yellow text
line('Plain text output'); // No color
comment('Background note'); // Gray text
alert('Important notice'); // Bordered alert box
newLine(); // One blank line
newLine(3); // Three blank lines
// Exit the command with an error
abort('Fatal error occurred'); // Prints red, exits with code 1
}
| Method | Description |
|---|---|
info(String message) |
Print blue text |
error(String message) |
Print red text |
success(String message) |
Print green text |
warning(String message) |
Print yellow text |
line(String message) |
Print plain text (no color) |
newLine([int count = 1]) |
Print blank lines |
comment(String message) |
Print gray/muted text |
alert(String message) |
Print a bordered alert box |
abort([String? message, int exitCode = 1]) |
Exit the command with an error |
Spinner and Progress
Spinners and progress bars provide visual feedback during long-running operations.
Using withSpinner
Wrap an async task with an automatic spinner:
final projectFiles = await withSpinner(
task: () async {
await sleep(2);
return ['pubspec.yaml', 'lib/main.dart', 'README.md'];
},
message: 'Analyzing project structure',
successMessage: 'Project analysis complete',
errorMessage: 'Failed to analyze project',
);
info('Found ${projectFiles.length} key files');
| Parameter | Type | Description |
|---|---|---|
task |
Future<T> Function() |
The async function to execute |
message |
String |
Text shown while spinner runs |
successMessage |
String? |
Shown on success |
errorMessage |
String? |
Shown on failure |
Manual Spinner Control
For multi-step workflows, create a spinner and control it manually:
final spinner = createSpinner('Deploying to production');
spinner.start();
try {
await runProcess('flutter clean', silent: true);
spinner.update('Building release version');
await runProcess('flutter build web --release', silent: true);
spinner.update('Uploading to server');
await runProcess('./deploy.sh', silent: true);
spinner.stop(completionMessage: 'Deployment completed', success: true);
} catch (e) {
spinner.stop(completionMessage: 'Deployment failed: $e', success: false);
}
ConsoleSpinner methods:
| Method | Description |
|---|---|
start([String? message]) |
Start the spinner animation |
update(String message) |
Change the displayed message |
stop({String? completionMessage, bool success = true}) |
Stop the spinner |
Progress Bar
Create and manage a progress bar manually:
final progress = progressBar(100, message: 'Processing files');
progress.start();
for (int i = 0; i < 100; i++) {
await Future.delayed(Duration(milliseconds: 50));
progress.tick();
}
progress.complete('All files processed');
ConsoleProgressBar methods:
| Method / Property | Description |
|---|---|
start() |
Start the progress bar |
tick([int amount = 1]) |
Increment progress |
update(int value) |
Set progress to a specific value |
updateMessage(String newMessage) |
Change the displayed message |
complete([String? completionMessage]) |
Complete with optional message |
stop() |
Stop without completing |
current |
Current progress value (getter) |
percentage |
Progress as a percentage 0-100 (getter) |
Processing Items with Progress
Process a list of items with automatic progress tracking:
// Async processing
final results = await withProgress<File, String>(
items: findFiles('lib/', extension: '.dart'),
process: (file, index) async {
return file.path;
},
message: 'Analyzing Dart files',
completionMessage: 'Analysis complete',
);
// Synchronous processing
final upperItems = withProgressSync<String, String>(
items: ['a', 'b', 'c', 'd', 'e'],
process: (item, index) => item.toUpperCase(),
message: 'Converting items',
);
API Helper
The api helper provides a simplified wrapper around Dio for making HTTP requests:
// GET request
final userData = await api((request) =>
request.get('https://api.example.com/users/1')
);
// POST request
final result = await api((request) =>
request.post(
'https://api.example.com/items',
data: {'name': 'New Item', 'price': 19.99},
)
);
// PUT request
final updateResult = await api((request) =>
request.put(
'https://api.example.com/items/42',
data: {'name': 'Updated Item', 'price': 29.99},
)
);
// DELETE request
final deleteResult = await api((request) =>
request.delete('https://api.example.com/items/42')
);
// PATCH request
final patchResult = await api((request) =>
request.patch(
'https://api.example.com/items/42',
data: {'price': 24.99},
)
);
// With query parameters
final searchResults = await api((request) =>
request.get(
'https://api.example.com/search',
queryParameters: {'q': 'keyword', 'limit': 10},
)
);
Combine with withSpinner for a better user experience:
final data = await withSpinner(
task: () => api((request) =>
request.get('https://api.example.com/config')
),
message: 'Loading configuration',
);
The ApiService supports get, post, put, delete, and patch methods, each accepting optional queryParameters, data, options, and cancelToken.
File System Helpers
Built-in file system helpers so you don't need to import dart:io:
// Check existence
if (fileExists('lib/config/app.dart')) { /* ... */ }
if (directoryExists('lib/app/models')) { /* ... */ }
// Read files
String content = await readFile('pubspec.yaml');
String contentSync = readFileSync('pubspec.yaml');
// Write files
await writeFile('lib/generated/output.dart', 'class Output {}');
writeFileSync('lib/generated/output.dart', 'class Output {}');
// Append to a file
await appendFile('log.txt', 'New log entry\n');
// Ensure a directory exists
await ensureDirectory('lib/generated');
// Delete and copy files
await deleteFile('lib/generated/output.dart');
await copyFile('lib/config/app.dart', 'lib/config/app.bak.dart');
| Method | Description |
|---|---|
fileExists(String path) |
Returns true if the file exists |
directoryExists(String path) |
Returns true if the directory exists |
readFile(String path) |
Read file as string (async) |
readFileSync(String path) |
Read file as string (sync) |
writeFile(String path, String content) |
Write content to file (async) |
writeFileSync(String path, String content) |
Write content to file (sync) |
appendFile(String path, String content) |
Append content to file |
ensureDirectory(String path) |
Create directory if it doesn't exist |
deleteFile(String path) |
Delete a file |
copyFile(String source, String destination) |
Copy a file |
JSON and YAML Helpers
Read and write JSON and YAML files with built-in helpers:
// Read JSON as Map
Map<String, dynamic> config = await readJson('config.json');
// Read JSON as List
List<dynamic> items = await readJsonArray('lib/app/commands/commands.json');
// Write JSON (pretty printed by default)
await writeJson('output.json', {'name': 'MyApp', 'version': '1.0.0'});
// Write compact JSON
await writeJson('output.json', data, pretty: false);
// Append to a JSON array file (with duplicate prevention)
await appendToJsonArray(
'lib/app/commands/commands.json',
{'name': 'my_command', 'category': 'app', 'script': 'my_command.dart'},
uniqueKey: 'name',
);
// Read YAML as Map
Map<String, dynamic> pubspec = await readYaml('pubspec.yaml');
info('Project: ${pubspec['name']}');
| Method | Description |
|---|---|
readJson(String path) |
Read JSON file as Map<String, dynamic> |
readJsonArray(String path) |
Read JSON file as List<dynamic> |
writeJson(String path, dynamic data, {bool pretty = true}) |
Write data as JSON |
appendToJsonArray(String path, Map item, {String? uniqueKey}) |
Append to a JSON array file |
readYaml(String path) |
Read YAML file as Map<String, dynamic> |
Dart File Manipulation
Helpers for programmatically editing Dart source files -- useful when building scaffolding tools:
// Add an import (skips if already present)
await addImport(
'lib/bootstrap/providers.dart',
"import '/app/providers/firebase_provider.dart';",
);
// Insert code before the last closing brace
await insertBeforeClosingBrace(
'lib/bootstrap/providers.dart',
' FirebaseProvider(),',
);
// Check if a file contains a string
bool hasImport = await fileContains(
'lib/bootstrap/providers.dart',
'firebase_provider',
);
// Check if a file matches a regex pattern
bool hasClass = await fileContainsPattern(
'lib/app/models/user.dart',
RegExp(r'class User'),
);
| Method | Description |
|---|---|
addImport(String filePath, String importStatement) |
Add import to Dart file (skips if present) |
insertBeforeClosingBrace(String filePath, String code) |
Insert code before last } in file |
fileContains(String filePath, String identifier) |
Check if file contains a string |
fileContainsPattern(String filePath, Pattern pattern) |
Check if file matches a pattern |
Directory Helpers
Helpers for working with directories and finding files:
// List directory contents
var entities = listDirectory('lib/app/models');
var allEntities = listDirectory('lib/', recursive: true);
// Find files by extension
List<File> dartFiles = findFiles(
'lib/app/models',
extension: '.dart',
recursive: true,
);
// Find files by name pattern
List<File> testFiles = findFiles(
'test/',
namePattern: RegExp(r'_test\.dart$'),
);
// Delete a directory recursively
await deleteDirectory('build/');
// Copy a directory recursively
await copyDirectory('lib/templates', 'lib/generated');
| Method | Description |
|---|---|
listDirectory(String path, {bool recursive = false}) |
List directory contents |
findFiles(String directory, {String? extension, Pattern? namePattern, bool recursive = true}) |
Find files matching criteria |
deleteDirectory(String path) |
Delete directory recursively |
copyDirectory(String source, String destination) |
Copy directory recursively |
Case Conversion Helpers
Convert strings between naming conventions without importing the recase package:
String input = 'user profile page';
snakeCase(input); // user_profile_page
camelCase(input); // userProfilePage
pascalCase(input); // UserProfilePage
titleCase(input); // User Profile Page
kebabCase(input); // user-profile-page
constantCase(input); // USER_PROFILE_PAGE
| Method | Output Format | Example |
|---|---|---|
snakeCase(String input) |
snake_case |
user_profile |
camelCase(String input) |
camelCase |
userProfile |
pascalCase(String input) |
PascalCase |
UserProfile |
titleCase(String input) |
Title Case |
User Profile |
kebabCase(String input) |
kebab-case |
user-profile |
constantCase(String input) |
CONSTANT_CASE |
USER_PROFILE |
Project Path Helpers
Getters for standard Nylo Website project directories, returning paths relative to the project root:
info(modelsPath); // lib/app/models
info(controllersPath); // lib/app/controllers
info(widgetsPath); // lib/resources/widgets
info(pagesPath); // lib/resources/pages
info(commandsPath); // lib/app/commands
info(configPath); // lib/config
info(providersPath); // lib/app/providers
info(eventsPath); // lib/app/events
info(networkingPath); // lib/app/networking
info(themesPath); // lib/resources/themes
// Build a custom path
String customPath = projectPath('app/services/auth_service.dart');
// Returns: lib/app/services/auth_service.dart
| Property | Path |
|---|---|
modelsPath |
lib/app/models |
controllersPath |
lib/app/controllers |
widgetsPath |
lib/resources/widgets |
pagesPath |
lib/resources/pages |
commandsPath |
lib/app/commands |
configPath |
lib/config |
providersPath |
lib/app/providers |
eventsPath |
lib/app/events |
networkingPath |
lib/app/networking |
themesPath |
lib/resources/themes |
projectPath(String relativePath) |
Resolves a relative path within the project |
Platform Helpers
Check the platform and access environment variables:
if (isWindows) {
info('Running on Windows');
} else if (isMacOS) {
info('Running on macOS');
} else if (isLinux) {
info('Running on Linux');
}
info('Working in: $workingDirectory');
String home = env('HOME', '/default/path');
| Property / Method | Description |
|---|---|
isWindows |
true if running on Windows |
isMacOS |
true if running on macOS |
isLinux |
true if running on Linux |
workingDirectory |
Current working directory path |
env(String key, [String defaultValue = '']) |
Read system environment variable |
Dart and Flutter Commands
Run common Dart and Flutter CLI commands as helper methods. Each returns the process exit code:
// Format a Dart file or directory
await dartFormat('lib/app/models/user.dart');
// Run dart analyze
int analyzeResult = await dartAnalyze('lib/');
// Run flutter pub get
await flutterPubGet();
// Run flutter clean
await flutterClean();
// Build for a target with additional args
await flutterBuild('apk', args: ['--release', '--split-per-abi']);
await flutterBuild('web', args: ['--release']);
// Run flutter test
await flutterTest();
await flutterTest('test/unit/');
| Method | Description |
|---|---|
dartFormat(String path) |
Run dart format on a file or directory |
dartAnalyze([String? path]) |
Run dart analyze |
flutterPubGet() |
Run flutter pub get |
flutterClean() |
Run flutter clean |
flutterBuild(String target, {List<String> args}) |
Run flutter build <target> |
flutterTest([String? path]) |
Run flutter test |
Validation Helpers
Helpers for validating and cleaning user input for code generation:
// Validate a Dart identifier
if (!isValidDartIdentifier('MyClass')) {
error('Invalid Dart identifier');
}
// Require a non-empty first argument (aborts if missing)
String name = requireArgument(result, message: 'Please provide a name');
// Clean a class name (PascalCase, remove suffixes)
String className = cleanClassName('user_model', removeSuffixes: ['_model']);
// Returns: 'User'
// Clean a file name (snake_case with extension)
String fileName = cleanFileName('UserModel', extension: '.dart');
// Returns: 'user_model.dart'
| Method | Description |
|---|---|
isValidDartIdentifier(String name) |
Validate a Dart identifier name |
requireArgument(CommandResult result, {String? message}) |
Require non-empty first argument or abort |
cleanClassName(String name, {List<String> removeSuffixes}) |
Clean and PascalCase a class name |
cleanFileName(String name, {String extension = '.dart'}) |
Clean and snake_case a file name |
File Scaffolding
Create one or many files with content using the scaffolding system.
Single File
await scaffold(
path: 'lib/app/services/auth_service.dart',
content: '''
class AuthService {
Future<bool> login(String email, String password) async {
return false;
}
}
''',
force: false,
successMessage: 'AuthService created',
);
| Parameter | Type | Description |
|---|---|---|
path |
String |
File path to create |
content |
String |
File content |
force |
bool |
Overwrite if exists (default: false) |
successMessage |
String? |
Message shown on success |
Multiple Files
await scaffoldMany([
ScaffoldFile(
path: 'lib/app/models/product.dart',
content: 'class Product {}',
successMessage: 'Product model created',
),
ScaffoldFile(
path: 'lib/app/networking/product_api_service.dart',
content: 'class ProductApiService {}',
successMessage: 'Product API service created',
),
], force: false);
The ScaffoldFile class:
| Property | Type | Description |
|---|---|---|
path |
String |
File path to create |
content |
String |
File content |
successMessage |
String? |
Message shown on success |
Task Runner
Run a series of named tasks with automatic status output.
Basic Task Runner
await runTasks([
CommandTask(
'Clean project',
() => runProcess('flutter clean', silent: true),
),
CommandTask(
'Fetch dependencies',
() => runProcess('flutter pub get', silent: true),
),
CommandTask(
'Run tests',
() => runProcess('flutter test', silent: true),
stopOnError: true,
),
]);
Task Runner with Spinner
await runTasksWithSpinner([
CommandTask(
'Preparing release',
() async {
await flutterClean();
await flutterPubGet();
},
),
CommandTask(
'Building APK',
() => flutterBuild('apk', args: ['--release']),
),
]);
The CommandTask class:
| Property | Type | Default | Description |
|---|---|---|---|
name |
String |
required | Task name shown in output |
action |
Future<void> Function() |
required | Async function to execute |
stopOnError |
bool |
true |
Whether to stop remaining tasks on failure |
Table Output
Display formatted ASCII tables in the console:
table(
['Name', 'Version', 'Status'],
[
['nylo_framework', '7.0.0', 'installed'],
['nylo_support', '7.0.0', 'installed'],
['dio', '5.4.0', 'installed'],
],
);
Output:
┌─────────────────┬─────────┬───────────┐
│ Name │ Version │ Status │
├─────────────────┼─────────┼───────────┤
│ nylo_framework │ 7.0.0 │ installed │
│ nylo_support │ 7.0.0 │ installed │
│ dio │ 5.4.0 │ installed │
└─────────────────┴─────────┴───────────┘
Examples
Current Time Command
A simple command that displays the current time:
import 'package:nylo_framework/metro/ny_cli.dart';
void main(arguments) => _CurrentTimeCommand(arguments).run();
class _CurrentTimeCommand extends NyCustomCommand {
_CurrentTimeCommand(super.arguments);
@override
CommandBuilder builder(CommandBuilder command) {
command.addOption('format', defaultValue: 'HH:mm:ss');
return command;
}
@override
Future<void> handle(CommandResult result) async {
final format = result.getString("format");
final now = DateTime.now();
info("The current time is ${now.toIso8601String()}");
info("Requested format: $format");
}
}
Download Fonts Command
A command that downloads and installs Google Fonts into the project:
import 'package:nylo_framework/metro/ny_cli.dart';
void main(arguments) => _DownloadFontsCommand(arguments).run();
class _DownloadFontsCommand extends NyCustomCommand {
_DownloadFontsCommand(super.arguments);
@override
CommandBuilder builder(CommandBuilder command) {
command.addOption('font', abbr: 'f', help: 'Font family name');
command.addFlag('verbose', abbr: 'v', defaultValue: false);
return command;
}
@override
Future<void> handle(CommandResult result) async {
final fontName = result.getString('font') ??
prompt('Enter font family name:', defaultValue: 'Roboto');
final verbose = result.getBool('verbose') ?? false;
await withSpinner(
task: () async {
await ensureDirectory('assets/fonts');
final fontData = await api((request) =>
request.get('https://fonts.google.com/download?family=$fontName')
);
if (fontData != null) {
await writeFile('assets/fonts/$fontName.ttf', fontData.toString());
}
},
message: 'Downloading $fontName font',
successMessage: '$fontName font installed',
errorMessage: 'Failed to download $fontName',
);
if (verbose) {
info('Font saved to: assets/fonts/$fontName.ttf');
}
}
}
Deployment Pipeline Command
A command that runs a full deployment pipeline using the task runner:
import 'package:nylo_framework/metro/ny_cli.dart';
void main(arguments) => _DeployCommand(arguments).run();
class _DeployCommand extends NyCustomCommand {
_DeployCommand(super.arguments);
@override
CommandBuilder builder(CommandBuilder command) {
command.addOption(
'environment',
abbr: 'e',
defaultValue: 'development',
allowed: ['development', 'staging', 'production'],
);
command.addFlag('skip-tests', defaultValue: false);
return command;
}
@override
Future<void> handle(CommandResult result) async {
final env = result.getString('environment') ?? 'development';
final skipTests = result.getBool('skip-tests') ?? false;
alert('Deploying to $env');
newLine();
if (env == 'production') {
if (!confirm('Are you sure you want to deploy to production?')) {
abort('Deployment canceled');
}
}
final tasks = <CommandTask>[
CommandTask('Clean project', () => flutterClean()),
CommandTask('Fetch dependencies', () => flutterPubGet()),
];
if (!skipTests) {
tasks.add(CommandTask(
'Run tests',
() => flutterTest(),
stopOnError: true,
));
}
tasks.add(CommandTask(
'Build for web',
() => flutterBuild('web', args: ['--release']),
));
await runTasksWithSpinner(tasks);
newLine();
success('Deployment to $env completed');
table(
['Step', 'Status'],
tasks.map((t) => [t.name, 'Done']).toList(),
);
}
}