diff --git a/docs/cli/commands/token.mdx b/docs/cli/commands/token.mdx new file mode 100644 index 00000000..f349dfa3 --- /dev/null +++ b/docs/cli/commands/token.mdx @@ -0,0 +1,47 @@ +--- +title: Globe Tokens +description: Create, Delete & List globe auth tokens from the command line. +--- + +# Create + +The `create` command allows you to create auth tokens for your projects. You can use this token to +login to Globe in any environment. + +## Usage + +You can run the command interactively by running + +```bash +globe token create +``` + +or in-lined by providing necessary arguments + +- `--name`- specify name to identity the token. +- `--expiry` - specify lifespan of the token. +- `--project` - specify projects(s) to associate the token with. + +```bash +globe token create --name="Foo Bar" --expiry="yyyy-mm-dd" --project="project-ids-go-here" +``` + +# List Tokens + +The `list` command lists all tokens associated with the current project. + +## Usage + +```bash +globe token list +``` + +# Delete Token + +The `delete` command allows you to delete token by providing token ID. + +## Usage + +```bash +globe token delete --tokenId="token-id-goes-here" +``` \ No newline at end of file diff --git a/packages/globe_cli/lib/src/command_runner.dart b/packages/globe_cli/lib/src/command_runner.dart index cadcb226..6f203aa7 100644 --- a/packages/globe_cli/lib/src/command_runner.dart +++ b/packages/globe_cli/lib/src/command_runner.dart @@ -50,6 +50,7 @@ class GlobeCliCommandRunner extends CompletionCommandRunner { addCommand(LinkCommand()); addCommand(UnlinkCommand()); addCommand(BuildLogsCommand()); + addCommand(TokenCommand()); } final Logger _logger; diff --git a/packages/globe_cli/lib/src/commands/commands.dart b/packages/globe_cli/lib/src/commands/commands.dart index b5c486e6..8b8f39b7 100644 --- a/packages/globe_cli/lib/src/commands/commands.dart +++ b/packages/globe_cli/lib/src/commands/commands.dart @@ -2,4 +2,5 @@ export 'deploy_command.dart'; export 'link_command.dart'; export 'login_command.dart'; export 'logout_command.dart'; +export 'token_command.dart'; export 'unlink_command.dart'; diff --git a/packages/globe_cli/lib/src/commands/token/token_create_command.dart b/packages/globe_cli/lib/src/commands/token/token_create_command.dart new file mode 100644 index 00000000..1ad8bf0c --- /dev/null +++ b/packages/globe_cli/lib/src/commands/token/token_create_command.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:mason_logger/mason_logger.dart'; + +import '../../command.dart'; +import '../../exit.dart'; +import '../../utils/api.dart'; +import '../../utils/prompts.dart'; + +class TokenCreateCommand extends BaseGlobeCommand { + TokenCreateCommand() { + argParser + ..addOption( + 'name', + abbr: 'n', + help: 'Specify name to identity token.', + ) + ..addOption( + 'expiry', + abbr: 'e', + help: 'Specify lifespan of token.', + ) + ..addMultiOption( + 'project', + help: 'Specify projects(s) to associate token with.', + ); + } + + @override + String get description => 'Create globe auth token.'; + + @override + String get name => 'create'; + + @override + FutureOr run() async { + requireAuth(); + + final validated = await scope.validate(); + + final name = argResults?['name']?.toString() ?? + logger.prompt('❓ Provide name for token:'); + final dateString = argResults?['expiry']?.toString() ?? + logger.prompt('❓ Set Expiry (yyyy-mm-dd):'); + + final expiry = DateTime.tryParse(dateString); + if (expiry == null) { + logger.err( + 'Invalid date format.\nDate format should be ${cyan.wrap('2012-02-27')} or ${cyan.wrap('2012-02-27 13:27:00')}', + ); + exitOverride(1); + } + + final projects = await selectProjects( + validated.organization, + logger: logger, + api: api, + scope: scope, + ids: argResults?['project'] as List?, + ); + final projectNames = projects.map((e) => cyan.wrap(e.slug)).join(', '); + + final createTokenProgress = + logger.progress('Creating Token for $projectNames'); + + try { + final token = await api.createToken( + orgId: validated.organization.id, + name: name, + projectUuids: projects.map((e) => e.id).toList(), + expiresAt: expiry, + ); + createTokenProgress.complete( + "Here's your token:\nID: ${cyan.wrap(token.id)}\nToken: ${cyan.wrap(token.value)}", + ); + return ExitCode.success.code; + } on ApiException catch (e) { + createTokenProgress.fail('✗ Failed to create token: ${e.message}'); + return ExitCode.software.code; + } catch (e, s) { + createTokenProgress.fail('✗ Failed to create token: $e'); + logger.detail(s.toString()); + return ExitCode.software.code; + } + } +} diff --git a/packages/globe_cli/lib/src/commands/token/token_delete_command.dart b/packages/globe_cli/lib/src/commands/token/token_delete_command.dart new file mode 100644 index 00000000..425e6e8d --- /dev/null +++ b/packages/globe_cli/lib/src/commands/token/token_delete_command.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:mason_logger/mason_logger.dart'; + +import '../../command.dart'; +import '../../utils/api.dart'; + +class TokenDeleteCommand extends BaseGlobeCommand { + TokenDeleteCommand() { + argParser.addOption( + 'tokenId', + abbr: 't', + help: 'Specify globe auth token id.', + ); + } + @override + String get description => 'Delete globe auth token.'; + + @override + String get name => 'delete'; + + @override + FutureOr run() async { + requireAuth(); + + final validated = await scope.validate(); + final tokenId = (argResults?['tokenId'] as String?) ?? + logger.prompt('❓ Provide id for token:'); + + final deleteTokenProgress = + logger.progress('Deleting Token: ${cyan.wrap(tokenId)}'); + + try { + await api.deleteToken( + orgId: validated.organization.id, + tokenId: tokenId, + ); + deleteTokenProgress.complete('Token deleted'); + } on ApiException catch (e) { + deleteTokenProgress.fail('✗ Failed to delete token: ${e.message}'); + return ExitCode.software.code; + } catch (e, s) { + deleteTokenProgress.fail('✗ Failed to delete token: $e'); + logger.detail(s.toString()); + return ExitCode.software.code; + } + + return 0; + } +} diff --git a/packages/globe_cli/lib/src/commands/token/token_list_command.dart b/packages/globe_cli/lib/src/commands/token/token_list_command.dart new file mode 100644 index 00000000..39051d6f --- /dev/null +++ b/packages/globe_cli/lib/src/commands/token/token_list_command.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:mason_logger/mason_logger.dart'; + +import '../../command.dart'; +import '../../utils/api.dart'; + +class TokenListCommand extends BaseGlobeCommand { + @override + String get description => 'List globe auth tokens for current project'; + + @override + String get name => 'list'; + + @override + FutureOr? run() async { + requireAuth(); + + final validated = await scope.validate(); + final projectName = cyan.wrap(validated.project.slug); + + final listTokenProgress = + logger.progress('Listing Tokens for $projectName'); + + try { + final tokens = await api.listTokens( + orgId: validated.organization.id, + projectUuids: [validated.project.id], + ); + if (tokens.isEmpty) { + listTokenProgress.fail('No Tokens found for $projectName'); + return ExitCode.success.code; + } + + String tokenLog(Token token) => ''' +---------------------------------- + ID: ${cyan.wrap(token.uuid)} + Name: ${token.name} + Expiry: ${token.expiresAt.toLocal()}'''; + + listTokenProgress.complete( + 'Tokens for $projectName\n${tokens.map(tokenLog).join('\n')}', + ); + + return ExitCode.success.code; + } on ApiException catch (e) { + listTokenProgress.fail('✗ Failed to list tokens: ${e.message}'); + return ExitCode.software.code; + } catch (e, s) { + listTokenProgress.fail('✗ Failed to list tokens: $e'); + logger.detail(s.toString()); + return ExitCode.software.code; + } + } +} diff --git a/packages/globe_cli/lib/src/commands/token_command.dart b/packages/globe_cli/lib/src/commands/token_command.dart new file mode 100644 index 00000000..0d164731 --- /dev/null +++ b/packages/globe_cli/lib/src/commands/token_command.dart @@ -0,0 +1,17 @@ +import '../command.dart'; +import 'token/token_create_command.dart'; +import 'token/token_delete_command.dart'; +import 'token/token_list_command.dart'; + +class TokenCommand extends BaseGlobeCommand { + TokenCommand() { + addSubcommand(TokenCreateCommand()); + addSubcommand(TokenDeleteCommand()); + addSubcommand(TokenListCommand()); + } + @override + String get description => 'Manage globe auth tokens.'; + + @override + String get name => 'token'; +} diff --git a/packages/globe_cli/lib/src/utils/api.dart b/packages/globe_cli/lib/src/utils/api.dart index f139963c..8e0dd87c 100644 --- a/packages/globe_cli/lib/src/utils/api.dart +++ b/packages/globe_cli/lib/src/utils/api.dart @@ -219,6 +219,73 @@ class GlobeApi { return Deployment.fromJson(response); } + + Future<({String id, String value})> createToken({ + required String orgId, + required String name, + required List projectUuids, + required DateTime expiresAt, + }) async { + requireAuth(); + + final createTokenPath = '/orgs/$orgId/api-tokens'; + logger.detail('API Request: POST $createTokenPath'); + + final body = json.encode({ + 'name': name, + 'projectUuids': projectUuids, + 'expiresAt': expiresAt.toUtc().toIso8601String(), + }); + + // create token + final response = _handleResponse( + await http.post(_buildUri(createTokenPath), headers: headers, body: body), + )! as Map; + final token = Token.fromJson(response); + + final generateTokenPath = '/orgs/$orgId/api-tokens/${token.uuid}/generate'; + logger.detail('API Request: GET $generateTokenPath'); + + // get token value + final tokenValue = _handleResponse( + await http.get(_buildUri(generateTokenPath), headers: headers), + )! as String; + + return (id: token.uuid, value: tokenValue); + } + + Future> listTokens({ + required String orgId, + required List projectUuids, + }) async { + requireAuth(); + + final listTokensPath = + '/orgs/$orgId/api-tokens?projects=${projectUuids.join(',')}'; + logger.detail('API Request: GET $listTokensPath'); + + final response = _handleResponse( + await http.get(_buildUri(listTokensPath), headers: headers), + )! as List; + + return response + .map((e) => Token.fromJson(e as Map)) + .toList(); + } + + Future deleteToken({ + required String orgId, + required String tokenId, + }) async { + requireAuth(); + + final deleteTokenPath = '/orgs/$orgId/api-tokens/$tokenId'; + logger.detail('API Request: DELETE $deleteTokenPath'); + + _handleResponse( + await http.delete(_buildUri(deleteTokenPath), headers: headers), + )! as Map; + } } class Settings { @@ -564,3 +631,41 @@ enum OrganizationType { } } } + +class Token { + final String uuid; + final String name; + final String organizationUuid; + final DateTime expiresAt; + final List cliTokenClaimProject; + + const Token._({ + required this.uuid, + required this.name, + required this.organizationUuid, + required this.expiresAt, + required this.cliTokenClaimProject, + }); + + factory Token.fromJson(Map json) { + return switch (json) { + { + 'uuid': final String uuid, + 'name': final String name, + 'organizationUuid': final String organizationUuid, + 'expiresAt': final String expiresAt, + 'projects': final List projects, + } => + Token._( + uuid: uuid, + name: name, + organizationUuid: organizationUuid, + expiresAt: DateTime.parse(expiresAt), + cliTokenClaimProject: projects + .map((e) => (e as Map)['projectUuid'].toString()) + .toList(), + ), + _ => throw const FormatException('Token'), + }; + } +} diff --git a/packages/globe_cli/lib/src/utils/prompts.dart b/packages/globe_cli/lib/src/utils/prompts.dart index 3e231057..abb0bb4c 100644 --- a/packages/globe_cli/lib/src/utils/prompts.dart +++ b/packages/globe_cli/lib/src/utils/prompts.dart @@ -314,3 +314,64 @@ Future selectProject( return projects.firstWhere((p) => p.id == selectedProject); } + +/// Prompts the user to select single or multiple projects. +/// +/// Optionally pass [ids] to only verify projects Ids actually exist +Future> selectProjects( + Organization organization, { + required Logger logger, + required GlobeApi api, + required GlobeScope scope, + List? ids, +}) async { + logger.detail('Fetching organization projects'); + final projects = await api.getProjects(org: organization.id); + logger.detail('Found ${projects.length} projects'); + + if (projects.isEmpty) { + logger.detail( + 'No projects found, you need to create a new project first.', + ); + logger.err('No projects found.'); + exitOverride(1); + } + + /// If ids passed, verify they exist + if (ids != null && ids.isNotEmpty) { + final projectsById = projects + .fold>({}, (prev, curr) => prev..[curr.id] = curr); + final invalidIds = ids.where((id) => projectsById[id] == null); + if (invalidIds.isNotEmpty) { + logger.err('Project not found: ${cyan.wrap(invalidIds.join(', '))}.'); + exitOverride(1); + } + return ids.map((id) => projectsById[id]!).toList(); + } + + /// If there's only one, automatically select it. + if (projects.length == 1) { + final project = projects.first; + logger.detail('Automatically selecting ${project.slug}.'); + return projects; + } + + final projectsBySlug = projects + .fold>({}, (prev, curr) => prev..[curr.slug] = curr); + + /// Ask user to choose zero or more options. + final selections = logger.chooseAny( + '❓ Select projects to associate token with:', + choices: projectsBySlug.keys.toList(), + ); + + if (selections.isEmpty) { + logger.detail( + 'No projects selected, you need to select atleast one project.', + ); + logger.err('No projects selected.'); + exitOverride(1); + } + + return selections.map((e) => projectsBySlug[e]!).toList(); +}