Skip to content

Commit ce9f0cb

Browse files
authored
chore(cli): Clean up console input/output (#321)
- Use `package:dart_console` for prompting since it handles control sequences better - Add structured output to `list` commands
1 parent 7e84d57 commit ce9f0cb

File tree

9 files changed

+93
-33
lines changed

9 files changed

+93
-33
lines changed

apps/cli/lib/src/commands/cloud_command.dart

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import 'package:celest_cli/src/context.dart';
99
import 'package:celest_cli/src/exceptions.dart';
1010
import 'package:celest_cli/src/utils/error.dart';
1111
import 'package:celest_cloud/celest_cloud.dart';
12+
import 'package:celest_cloud/src/proto/google/protobuf/timestamp.pb.dart' as pb;
1213
import 'package:celest_core/celest_core.dart';
14+
import 'package:dart_console/dart_console.dart';
1315
import 'package:logging/logging.dart';
1416
import 'package:mason_logger/mason_logger.dart' show Progress;
1517
import 'package:protobuf/protobuf.dart';
@@ -24,6 +26,9 @@ abstract base class BaseCloudCommand<R extends GeneratedMessage>
2426
/// The resource type of the service, e.g. `Project`.
2527
String get resourceType;
2628

29+
/// Creates an empty [Resource] protobuf message.
30+
R createEmptyResource();
31+
2732
/// The parsed arguments of the command.
2833
CloudCommandOptions get options => CloudCommandOptions(argResults!);
2934
}
@@ -33,9 +38,6 @@ base mixin CloudOperationCommand<R extends GeneratedMessage>
3338
/// Starts the operation in Celest Cloud.
3439
CloudOperation<R> callService();
3540

36-
/// Creates an empty [Resource] protobuf message.
37-
R createEmptyResource();
38-
3941
CloudVerbs get verbs;
4042

4143
/// The parsed arguments of the command.
@@ -287,6 +289,11 @@ typedef CloudListResult<R extends GeneratedMessage> = ({
287289
String? nextPageToken
288290
});
289291

292+
enum CloudListMode {
293+
raw,
294+
table,
295+
}
296+
290297
abstract base class CloudListCommand<R extends GeneratedMessage>
291298
extends BaseCloudCommand<R> {
292299
Future<CloudListResult<R>> callService();
@@ -326,6 +333,13 @@ abstract base class CloudListCommand<R extends GeneratedMessage>
326333
'show-deleted',
327334
negatable: false,
328335
help: 'If set, the command will show deleted ${resource}s',
336+
)
337+
..addOption(
338+
'display',
339+
abbr: 'd',
340+
allowed: CloudListMode.values.map((it) => it.name),
341+
defaultsTo: CloudListMode.table.name,
342+
help: 'The display mode for results',
329343
);
330344
}();
331345

@@ -343,15 +357,50 @@ abstract base class CloudListCommand<R extends GeneratedMessage>
343357
if (result.items.isEmpty) {
344358
cliLogger.info('No ${resourceType.toLowerCase()}s found');
345359
} else {
346-
for (final item in result.items) {
347-
stdout.writeln(item);
360+
switch (options.mode) {
361+
case CloudListMode.raw:
362+
_showRawResult(result);
363+
case CloudListMode.table:
364+
_showTableResult(result);
348365
}
349366
}
350367
return 0;
351368
} on CloudException catch (e) {
352369
throw CliException(e.message);
353370
}
354371
}
372+
373+
void _showRawResult(CloudListResult<R> result) {
374+
for (final item in result.items) {
375+
stdout.writeln(item);
376+
}
377+
}
378+
379+
void _showTableResult(CloudListResult<R> result) {
380+
final table = Table();
381+
final columns = createEmptyResource().info_.byName.keys;
382+
for (final column in columns) {
383+
table.insertColumn(header: column);
384+
}
385+
for (final item in result.items) {
386+
final row = <Object>[];
387+
for (final column in columns) {
388+
final tag = item.getTagNumber(column)!;
389+
if (!item.hasField(tag)) {
390+
row.add('<not set>');
391+
} else {
392+
final value = item.getField(tag) as Object;
393+
row.add(switch (value) {
394+
final pb.Timestamp ts => ts.toDateTime().toLocal(),
395+
final ProtobufEnum enum_ when enum_.value == 0 => '<not set>',
396+
final value => value,
397+
});
398+
}
399+
}
400+
table.insertRow(row);
401+
}
402+
stdout.writeln(table);
403+
}
355404
}
356405

357406
abstract base class CloudUpdateCommand<R extends GeneratedMessage>
@@ -524,4 +573,6 @@ extension type ListCommandArgResults(ArgResults argResults)
524573
String? get orderBy => option('order-by');
525574

526575
bool get showDeleted => flag('show-deleted');
576+
577+
CloudListMode get mode => CloudListMode.values.byName(option('display')!);
527578
}

apps/cli/lib/src/commands/organizations/get_organization_command.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ final class GetOrganizationCommand extends CloudGetCommand<Organization> {
1212
@override
1313
String get resourceType => 'Organization';
1414

15+
@override
16+
Organization createEmptyResource() => Organization();
17+
1518
@override
1619
Future<Organization?> callService() {
1720
return cloud.organizations.get(options.resourceId);

apps/cli/lib/src/commands/organizations/list_organizations_command.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ final class ListOrganizationsCommand extends CloudListCommand<Organization> {
1515
@override
1616
String? get parentResourceType => null;
1717

18+
@override
19+
Organization createEmptyResource() => Organization();
20+
1821
@override
1922
Future<CloudListResult<Organization>> callService() async {
2023
final result = await cloud.organizations.list(

apps/cli/lib/src/commands/project_environments/get_project_environment_command.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ final class GetProjectEnvironmentCommand
1313
@override
1414
String get resourceType => 'ProjectEnvironment';
1515

16+
@override
17+
ProjectEnvironment createEmptyResource() => ProjectEnvironment();
18+
1619
@override
1720
Future<ProjectEnvironment?> callService() {
1821
return cloud.projects.environments.get(options.resourceId);

apps/cli/lib/src/commands/project_environments/list_project_environments_command.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ final class ListProjectEnvironmentsCommand
1616
@override
1717
String? get parentResourceType => 'Project';
1818

19+
@override
20+
ProjectEnvironment createEmptyResource() => ProjectEnvironment();
21+
1922
@override
2023
Future<CloudListResult<ProjectEnvironment>> callService() async {
2124
final result = await cloud.projects.environments.list(

apps/cli/lib/src/commands/projects/get_project_command.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ final class GetProjectCommand extends CloudGetCommand<Project> {
1212
@override
1313
String get resourceType => 'Project';
1414

15+
@override
16+
Project createEmptyResource() => Project();
17+
1518
@override
1619
Future<Project?> callService() {
1720
return cloud.projects.get(options.resourceId);

apps/cli/lib/src/commands/projects/list_projects_command.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ final class ListProjectsCommand extends CloudListCommand<Project> {
1515
@override
1616
String? get parentResourceType => 'Organization';
1717

18+
@override
19+
Project createEmptyResource() => Project();
20+
1821
@override
1922
Future<CloudListResult<Project>> callService() async {
2023
final result = await cloud.projects.list(

apps/cli/lib/src/frontend/celest_frontend.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import 'package:celest_cli/src/utils/process.dart';
3131
import 'package:celest_cli/src/utils/recase.dart';
3232
import 'package:celest_cli/src/utils/run.dart';
3333
import 'package:celest_cloud/src/proto.dart' as pb;
34-
import 'package:dcli/dcli.dart' as dcli;
3534
import 'package:logging/logging.dart';
3635
import 'package:mason_logger/mason_logger.dart' show Progress;
3736
import 'package:stream_transform/stream_transform.dart';
@@ -500,7 +499,7 @@ final class CelestFrontend {
500499
var organizationId = organization?.organizationId;
501500
var organizationDisplayName = organization?.displayName;
502501
if (organizationId == null) {
503-
organizationDisplayName = dcli.ask(
502+
organizationDisplayName = cliLogger.prompt(
504503
'What should we call your organization?',
505504
);
506505
if (organizationDisplayName.isEmpty) {

apps/cli/lib/src/logging/cli_logger.dart

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import 'dart:convert';
21
import 'dart:io';
32

43
import 'package:celest_cli/src/context.dart';
54
import 'package:cli_util/cli_logging.dart' as cli_logging;
6-
import 'package:dcli/dcli.dart';
5+
import 'package:dart_console/dart_console.dart';
76
import 'package:mason_logger/mason_logger.dart' as mason_logger;
87

98
/// An interface which conforms to the [mason_logger.Logger] interface, but
@@ -14,6 +13,8 @@ class CliLogger implements mason_logger.Logger {
1413
this.progressOptions = const mason_logger.ProgressOptions(),
1514
});
1615

16+
static final Console console = Console();
17+
1718
@override
1819
final mason_logger.LogTheme theme;
1920

@@ -85,14 +86,7 @@ class CliLogger implements mason_logger.Logger {
8586
final suffix = ' ${defaultValue.toYesNo()}';
8687
final resolvedMessage = '$message$suffix ';
8788
stdout.write(resolvedMessage);
88-
String? input;
89-
try {
90-
input = stdin.readLineSync();
91-
} on FormatException catch (_) {
92-
// FormatExceptions can occur due to utf8 decoding errors
93-
// so we treat them as the user pressing enter (e.g. use `defaultValue`).
94-
stdout.writeln();
95-
}
89+
final input = console.readLine(cancelOnBreak: true);
9690
return input == null || input.isEmpty
9791
? defaultValue
9892
: input.toBoolean() ?? defaultValue;
@@ -153,20 +147,15 @@ class CliLogger implements mason_logger.Logger {
153147

154148
@override
155149
String prompt(String? message, {Object? defaultValue, bool hidden = false}) {
156-
if (ansiColorsEnabled) {
157-
return ask(
158-
message.toString(),
159-
defaultValue: defaultValue?.toString(),
160-
hidden: hidden,
161-
);
162-
}
163150
final resolvedDefaultValue = switch (defaultValue) {
164151
final defaultValue? when '$defaultValue'.isNotEmpty => ' ($defaultValue)',
165152
_ => ':',
166153
};
167154
final resolvedMessage = '$message$resolvedDefaultValue ';
168155
stdout.write(resolvedMessage);
169-
final input = hidden ? _readLineHiddenSync() : stdin.readLineSync()?.trim();
156+
final input = hidden
157+
? _readLineHiddenSync()
158+
: console.readLine(cancelOnBreak: true)?.trim();
170159
return input == null || input.isEmpty ? resolvedDefaultValue : input;
171160
}
172161

@@ -203,20 +192,23 @@ class CliLogger implements mason_logger.Logger {
203192
}
204193

205194
String _readLineHiddenSync() {
206-
const lineFeed = 10;
207-
const carriageReturn = 13;
208-
const delete = 127;
209-
final value = <int>[];
195+
const lineFeed = '\n';
196+
const carriageReturn = '\r';
197+
final value = <String>[];
210198

211199
try {
212200
stdin
213201
..echoMode = false
214202
..lineMode = false;
215-
int char;
203+
Key key;
204+
String char;
216205
do {
217-
char = stdin.readByteSync();
206+
key = console.readKey();
207+
char = key.char;
218208
if (char != lineFeed && char != carriageReturn) {
219-
final shouldDelete = char == delete && value.isNotEmpty;
209+
final shouldDelete = key.isControl &&
210+
key.controlChar == ControlCharacter.delete &&
211+
value.isNotEmpty;
220212
shouldDelete ? value.removeLast() : value.add(char);
221213
}
222214
} while (char != lineFeed && char != carriageReturn);
@@ -226,7 +218,7 @@ class CliLogger implements mason_logger.Logger {
226218
..echoMode = true;
227219
}
228220
stdout.writeln();
229-
return utf8.decode(value);
221+
return value.join();
230222
}
231223
}
232224

0 commit comments

Comments
 (0)