Skip to content

Commit

Permalink
feat: Multi project linking via CLI (#117)
Browse files Browse the repository at this point in the history
* allow multi project linking & unlinking

* fix lint issues

* migrate old schema & auto update project name if changed

* use --project arg as project id or slug

* restore token validation

* _

* _

* _

* _

* show all linked projects

* _

* show globe error details

* make token list use scope

* make token delete use scope
  • Loading branch information
codekeyz authored Dec 4, 2024
1 parent a10a667 commit 42a8799
Show file tree
Hide file tree
Showing 14 changed files with 339 additions and 115 deletions.
52 changes: 22 additions & 30 deletions packages/globe_cli/lib/src/command_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,17 @@ class GlobeCliCommandRunner extends CompletionCommandRunner<int> {
GetIt.instance.registerSingleton<GlobeMetadata>(metadata);
GetIt.instance.registerSingleton<GlobeScope>(scope);

final maybeToken = topLevelResults['token'];
final maybeProjectId = topLevelResults['project'];
final maybeOrgId = topLevelResults['org'];
final maybeProjectIdOrSlug = topLevelResults['project'] as String?;
final maybeToken = topLevelResults['token'] as String?;

// Load the current project scope.
auth.loadSession();
scope.loadScope(projectIdOrSlug: maybeProjectIdOrSlug);

Organization? org;
Project? project;

if (maybeToken != null) {
api.auth.loginWithApiToken(jwt: maybeToken as String);
api.auth.loginWithApiToken(jwt: maybeToken);
org = await selectOrganization(
logger: _logger,
api: api,
Expand All @@ -146,36 +148,26 @@ class GlobeCliCommandRunner extends CompletionCommandRunner<int> {
);
}

// Load the current project scope.
scope.loadScope();
auth.loadSession();

final currentSession = auth.currentSession;
if (maybeProjectIdOrSlug != null && !scope.hasScope()) {
org ??= await selectOrganization(logger: _logger, api: api);
final projects = await api.getProjects(org: org.id);

if (maybeOrgId != null) {
if (currentSession == null) throw Exception('Auth required.');
final orgs = await api.getOrganizations();
org = orgs.firstWhere(
(org) => org.id == maybeOrgId,
orElse: () => throw Exception('Project #$maybeProjectId not found.'),
final selectedProject = projects.firstWhere(
(project) =>
project.id == maybeProjectIdOrSlug ||
project.slug == maybeProjectIdOrSlug,
orElse: () =>
throw Exception('Project #$maybeProjectIdOrSlug not found.'),
);
}

if (maybeProjectId != null) {
if (currentSession == null) throw Exception('Auth required.');
if (org == null) throw Exception('Organization not found.');

final projects = await api.getProjects(org: org.id);
project = projects.firstWhere(
(project) => project.id == maybeProjectId,
orElse: () => throw Exception('Project #$maybeProjectId not found.'),
scope.setScope(
ScopeMetadata(
orgId: org.id,
projectId: selectedProject.id,
projectSlug: selectedProject.slug,
),
);
}

if (org != null && project != null) {
scope.setScope(orgId: org.id, projectId: project.id);
}

return await runCommand(topLevelResults) ?? ExitCode.success.code;
// TODO(rrousselGit) why are we checking FormatExceptions here?
} on FormatException catch (e, stackTrace) {
Expand Down
2 changes: 2 additions & 0 deletions packages/globe_cli/lib/src/commands/build_logs_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class BuildLogsCommand extends BaseGlobeCommand {
Future<int> run() async {
requireAuth();

await scope.selectOrLinkNewScope();

final validated = await _validator();
final deploymentId = argResults!['deployment'] as String?;

Expand Down
6 changes: 1 addition & 5 deletions packages/globe_cli/lib/src/commands/deploy_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import '../command.dart';
import '../utils/api.dart';
import '../utils/archiver.dart';
import '../utils/logs.dart';
import '../utils/prompts.dart';

/// `globe deploy`
///
Expand Down Expand Up @@ -45,10 +44,7 @@ class DeployCommand extends BaseGlobeCommand {

@override
Future<int> run() async {
// If there is no scope, ask the user to link the project.
if (!scope.hasScope()) {
await linkProject(logger: logger, api: api);
}
await scope.selectOrLinkNewScope();

final validated = await _validator();
if (validated.project.paused) {
Expand Down
34 changes: 34 additions & 0 deletions packages/globe_cli/lib/src/commands/link_command.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:cli_table/cli_table.dart';
import 'package:mason_logger/mason_logger.dart';

import '../command.dart';
Expand All @@ -7,6 +8,14 @@ import '../utils/prompts.dart';
///
/// Links the local project to a Globe project.
class LinkCommand extends BaseGlobeCommand {
LinkCommand() {
argParser.addFlag(
'show-all',
help: 'Show all linked projects',
negatable: false,
);
}

@override
String get description => 'Link this local project to a Globe project.';

Expand All @@ -15,6 +24,31 @@ class LinkCommand extends BaseGlobeCommand {

@override
Future<int> run() async {
final showLinked = argResults!['show-all'] as bool;
if (showLinked) {
final linkedProjects = scope.workspace;
if (linkedProjects.isEmpty) return ExitCode.success.code;

final table = Table(
header: [
cyan.wrap('Project'),
cyan.wrap('Project ID'),
cyan.wrap('Organization ID'),
],
columnWidths: [30, 30, 30],
);
for (final project in linkedProjects) {
table.add([
project.projectSlug,
project.projectId,
project.orgId,
]);
}

logger.info(table.toString());
return ExitCode.success.code;
}

requireAuth();

await linkProject(logger: logger, api: api);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class ProjectPauseCommand extends BaseGlobeCommand {
FutureOr<int> run() async {
requireAuth();

await scope.selectOrLinkNewScope();

final validated = await _validator();
final projectSlug = validated.project.slug;
final pauseProjectProgress =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class ProjectResumeCommand extends BaseGlobeCommand {
FutureOr<int> run() async {
requireAuth();

await scope.selectOrLinkNewScope();

final validated = await _validator();
final projectSlug = validated.project.slug;
final pauseProjectProgress =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import 'package:mason_logger/mason_logger.dart';

import '../../command.dart';
import '../../utils/api.dart';
import '../../utils/prompts.dart';

class TokenDeleteCommand extends BaseGlobeCommand {
TokenDeleteCommand() {
Expand All @@ -23,19 +22,16 @@ class TokenDeleteCommand extends BaseGlobeCommand {
@override
FutureOr<int> run() async {
requireAuth();
await scope.selectOrLinkNewScope();

final organization = await selectOrganization(logger: logger, api: api);
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: organization.id,
tokenId: tokenId,
);
await api.deleteToken(orgId: scope.current!.orgId, tokenId: tokenId);
deleteTokenProgress.complete('Token deleted');
} on ApiException catch (e) {
deleteTokenProgress.fail('✗ Failed to delete token: ${e.message}');
Expand Down
61 changes: 31 additions & 30 deletions packages/globe_cli/lib/src/commands/token/token_list_command.dart
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import 'dart:async';

import 'package:cli_table/cli_table.dart';
import 'package:mason_logger/mason_logger.dart';

import '../../command.dart';
import '../../utils/api.dart';
import '../../utils/prompts.dart';

class TokenListCommand extends BaseGlobeCommand {
TokenListCommand() {
argParser.addMultiOption(
'project',
help: 'Specify projects(s) to list token for.',
);
}
@override
String get description => 'List globe auth tokens for project(s)';

Expand All @@ -22,42 +16,49 @@ class TokenListCommand extends BaseGlobeCommand {
@override
FutureOr<int>? run() async {
requireAuth();
await scope.selectOrLinkNewScope();

final organization = await selectOrganization(logger: logger, api: api);
final projects = await selectProjects(
'Select projects to list tokens for:',
organization,
logger: logger,
api: api,
scope: scope,
ids: argResults?['project'] as List<String>?,
);

final projectNames = projects.map((e) => cyan.wrap(e.slug)).join(', ');
final listTokenProgress = logger.progress(
'Listing Tokens for $projectNames',
);
final projectName = scope.current!.projectSlug;
final listTokenProgress =
logger.progress('Listing tokens for $projectName');

try {
final tokens = await api.listTokens(
orgId: organization.id,
projectUuids: projects.map((e) => e.id).toList(),
orgId: scope.current!.orgId,
projectUuids: [scope.current!.projectId],
);
if (tokens.isEmpty) {
listTokenProgress.fail('No Tokens found for $projectNames');
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()}''';
final table = Table(
header: [
cyan.wrap('ID'),
cyan.wrap('Name'),
cyan.wrap('Expiry'),
],
columnWidths: [
tokens.first.uuid.length + 2,
tokens.first.name.length + 2,
25,
],
);

for (final token in tokens) {
table.add([
token.uuid,
token.name,
token.expiresAt.toLocal().toIso8601String(),
]);
}

listTokenProgress.complete(
'Tokens for $projectNames\n${tokens.map(tokenLog).join('\n')}',
'Found ${tokens.length} ${tokens.length > 1 ? 'tokens' : 'token'}',
);

logger.info(table.toString());

return ExitCode.success.code;
} on ApiException catch (e) {
listTokenProgress.fail('✗ Failed to list tokens: ${e.message}');
Expand Down
9 changes: 8 additions & 1 deletion packages/globe_cli/lib/src/commands/unlink_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ class UnlinkCommand extends BaseGlobeCommand {
@override
Future<int> run() async {
requireAuth();
scope.clear();

if (scope.hasScope()) {
scope.unlink();
} else if (scope.workspace.isNotEmpty) {
final selected = await scope.selectOrLinkNewScope(canLinkNew: false);
scope.unlinkScope(selected);
}

logger.success(
'Project unlinked successfully. To link this project again, run ${cyan.wrap('globe link')}.',
);
Expand Down
21 changes: 19 additions & 2 deletions packages/globe_cli/lib/src/utils/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class ApiException implements Exception {

final int statusCode;
final String message;

@override
String toString() => 'ApiException: [$statusCode] $message';
}

class GlobeApi {
Expand Down Expand Up @@ -108,29 +111,43 @@ class GlobeApi {
}

/// Gets all of the organizations that the current user is a member of.
List<Organization>? _orgsCache;
Future<List<Organization>> getOrganizations() async {
if (_orgsCache != null && _orgsCache!.isNotEmpty) {
logger.detail('Cached API Request: GET /user/orgs');
return _orgsCache!;
}

requireAuth();
logger.detail('API Request: GET /user/orgs');
final response = _handleResponse(
await http.get(_buildUri('/user/orgs'), headers: headers),
)! as List<Object?>;

return response
return _orgsCache = response
.cast<Map<String, Object?>>()
.map(Organization.fromJson)
.toList();
}

/// Gets all of the projects that the current user is a member of.
List<Project>? _projectsCache;
Future<List<Project>> getProjects({
required String org,
}) async {
if (_projectsCache != null && _projectsCache!.isNotEmpty) {
logger.detail('Cached API Request: GET /orgs/$org/projects');
return _projectsCache!;
}

requireAuth();
logger.detail('API Request: GET /orgs/$org/projects');
final response = _handleResponse(
await http.get(_buildUri('/orgs/$org/projects'), headers: headers),
)! as List<Object?>;
return response.cast<Map<String, Object?>>().map(Project.fromJson).toList();

return _projectsCache =
response.cast<Map<String, Object?>>().map(Project.fromJson).toList();
}

/// Creates a new project and returns it.
Expand Down
Loading

0 comments on commit 42a8799

Please sign in to comment.