Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.streamx.cli.commands.completion.CompleteSettingsKeysCommand;
import com.streamx.cli.commands.completion.CompleteSettingsSetKeysCommand;
import com.streamx.cli.commands.completion.CompleteTemplateIdsCommand;
import com.streamx.cli.commands.completion.CompleteTokenIdsCommand;
import com.streamx.cli.commands.completion.CompletionCommand;
import com.streamx.cli.commands.context.ContextCommand;
import com.streamx.cli.commands.info.InfoCommand;
Expand All @@ -32,7 +33,8 @@
CompleteNonDefaultTemplateIdsCommand.class,
CompleteSettingsKeysCommand.class,
CompleteSettingsSetKeysCommand.class,
CompleteContextNamesCommand.class
CompleteContextNamesCommand.class,
CompleteTokenIdsCommand.class
}
)
public class StreamxCommand extends AbstractCommandGroup {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.streamx.cli.commands.auth.login.LoginCommand;
import com.streamx.cli.commands.auth.logout.LogoutCommand;
import com.streamx.cli.commands.auth.token.TokenCommand;
import com.streamx.cli.commands.auth.whoami.WhoamiCommand;
import com.streamx.cli.framework.AbstractCommandGroup;
import picocli.CommandLine;
Expand All @@ -12,7 +13,8 @@
subcommands = {
LoginCommand.class,
LogoutCommand.class,
WhoamiCommand.class
WhoamiCommand.class,
TokenCommand.class
}
)
public class AuthCommand extends AbstractCommandGroup {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.streamx.cli.commands.auth.token;

import com.streamx.cli.commands.auth.token.create.CreateCommand;
import com.streamx.cli.commands.auth.token.list.ListCommand;
import com.streamx.cli.commands.auth.token.revoke.RevokeCommand;
import com.streamx.cli.framework.AbstractCommandGroup;
import picocli.CommandLine;

@CommandLine.Command(
name = "token",
header = "Manage personal access tokens",
description = {
"Personal access tokens authenticate the CLI in CI and other non-interactive environments.",
"Set STREAMX_PLATFORM_TOKEN=<token> to use one for platform calls, no login needed.",
"A token acts as you, with your permissions, and does not expire until revoked.",
"These subcommands need a login session: a token cannot manage tokens, so unset "
+ "STREAMX_PLATFORM_TOKEN to use them."
},
subcommands = {
CreateCommand.class,
ListCommand.class,
RevokeCommand.class
}
)
public class TokenCommand extends AbstractCommandGroup {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.streamx.cli.commands.auth.token.create;

import static com.streamx.cli.i18n.MessageProvider.msg;

import com.streamx.cli.framework.AbstractCommand;
import com.streamx.cli.framework.CliException;
import com.streamx.cli.framework.CommandResult;
import com.streamx.cli.platform.AccessTokens;
import com.streamx.cli.platform.PlatformClients;
import com.streamx.cli.platform.ProfileTokensApi;
import com.streamx.cli.platform.generated.model.PersonalAccessTokenResponse;
import io.quarkus.runtime.configuration.DurationConverter;
import java.time.Duration;
import picocli.CommandLine;

@CommandLine.Command(
name = "create",
header = "Create a personal access token",
description = {
"Prints the token once to standard output - copy it now, it cannot be retrieved again."
}
)
public class CreateCommand extends AbstractCommand<PersonalAccessTokenResponse> {

@CommandLine.Parameters(
index = "0",
paramLabel = "<name>",
description = "A label to recognize the token later (e.g. ci-github-actions)"
)
public String name;

@CommandLine.Option(
names = {"-e", "--expires-in"},
paramLabel = "<duration>",
description = {
"How long the token stays valid, e.g. 20m, 2h, 30d.",
"Omit for a token that never expires."
}
)
public String expiresIn;

@Override
public String getTextOutput(CommandResult<PersonalAccessTokenResponse> result) {
return result.getData().getToken();
}

@Override
public CommandResult<PersonalAccessTokenResponse> runCommand() {
AccessTokens.requireInteractiveSession();
Duration lifetime = parseLifetime(expiresIn);
try (PlatformClients client = PlatformClients.fromConfig()) {
PersonalAccessTokenResponse token = new ProfileTokensApi(client).create(name, lifetime);
System.err.println(msg.authTokenCreated(token.getName()));
return new CommandResult<>(token);
}
}

private static Duration parseLifetime(String value) {
if (value == null || value.isBlank()) {
return null;
}
Duration lifetime;
try {
lifetime = DurationConverter.parseDuration(value.trim());
} catch (RuntimeException unparseable) {
throw new CliException(msg.authTokenInvalidExpiry(value));
}
if (lifetime == null || lifetime.isZero() || lifetime.isNegative()) {
throw new CliException(msg.authTokenInvalidExpiry(value));
}
return lifetime;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.streamx.cli.commands.auth.token.list;

import static com.streamx.cli.i18n.MessageProvider.msg;

import com.streamx.cli.framework.AbstractCommand;
import com.streamx.cli.framework.CommandResult;
import com.streamx.cli.framework.TextTable;
import com.streamx.cli.platform.AccessTokens;
import com.streamx.cli.platform.PlatformClients;
import com.streamx.cli.platform.ProfileTokensApi;
import com.streamx.cli.platform.generated.model.PersonalAccessTokenSummary;
import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import picocli.CommandLine;

@CommandLine.Command(
name = "list",
header = "List your personal access tokens"
)
public class ListCommand extends AbstractCommand<List<PersonalAccessTokenSummary>> {

@CommandLine.Option(
names = {"-q", "--quiet"},
description = "Only display token ids, one per line"
)
public boolean quiet;

@Override
public String getTextOutput(CommandResult<List<PersonalAccessTokenSummary>> result) {
List<PersonalAccessTokenSummary> tokens = result.getData();

if (quiet) {
return tokens.stream()
.map(PersonalAccessTokenSummary::getId)
.filter(Objects::nonNull)
.collect(Collectors.joining("\n"));
}
if (tokens.isEmpty()) {
return msg.authTokenListEmpty();
}
return TextTable.render(
List.of("ID", "NAME", "CREATED", "LAST USED", "EXPIRES"),
tokens.stream()
.map(token -> Arrays.asList(
token.getId(),
token.getName(),
timestamp(token.getCreatedAt(), "-"),
timestamp(token.getLastUsedAt(), "never"),
expiry(token)))
.toList());
}

private static String timestamp(OffsetDateTime value, String absent) {
return value == null ? absent : value.toString();
}

private static String expiry(PersonalAccessTokenSummary token) {
if (Boolean.TRUE.equals(token.getExpired())) {
return "expired";
}
return timestamp(token.getExpiresAt(), "never");
}

@Override
public CommandResult<List<PersonalAccessTokenSummary>> runCommand() {
AccessTokens.requireInteractiveSession();
try (PlatformClients client = PlatformClients.fromConfig()) {
return new CommandResult<>(new ProfileTokensApi(client).list());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.streamx.cli.commands.auth.token.revoke;

import static com.streamx.cli.i18n.MessageProvider.msg;

import com.streamx.cli.framework.AbstractSilentCommand;
import com.streamx.cli.framework.CommandResult;
import com.streamx.cli.platform.AccessTokens;
import com.streamx.cli.platform.PlatformClients;
import com.streamx.cli.platform.ProfileTokensApi;
import com.streamx.cli.platform.TokenIdCompletionCandidates;
import picocli.CommandLine;

@CommandLine.Command(
name = "revoke",
header = "Revoke a personal access token"
)
public class RevokeCommand extends AbstractSilentCommand {

@CommandLine.Parameters(
index = "0",
paramLabel = "<id>",
description = "The token id (from 'streamx auth token list')",
completionCandidates = TokenIdCompletionCandidates.class
)
public String id;

@Override
public CommandResult<Void> runCommand() {
AccessTokens.requireInteractiveSession();
try (PlatformClients client = PlatformClients.fromConfig()) {
new ProfileTokensApi(client).revoke(id);
}
System.out.println(msg.authTokenRevoked());
return new CommandResult<>(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.streamx.cli.commands.completion;

import com.streamx.cli.framework.AbstractCommand;
import com.streamx.cli.framework.CommandResult;
import com.streamx.cli.platform.PlatformClients;
import com.streamx.cli.platform.ProfileTokensApi;
import com.streamx.cli.platform.generated.model.PersonalAccessTokenSummary;
import java.util.List;
import java.util.Objects;
import picocli.CommandLine;

@CommandLine.Command(
name = "__complete-token-ids",
hidden = true,
header = "Internal: list personal access token IDs for shell completion, one per line"
)
public class CompleteTokenIdsCommand extends AbstractCommand<List<String>> {

@Override
public CommandResult<List<String>> runCommand() {
try (PlatformClients client = PlatformClients.completion()) {
return new CommandResult<>(new ProfileTokensApi(client).list().stream()
.map(PersonalAccessTokenSummary::getId)
.filter(Objects::nonNull)
.toList());
} catch (RuntimeException anyFailure) {
return new CommandResult<>(List.of());
}
}

@Override
public String getTextOutput(CommandResult<List<String>> result) {
return String.join("\n", result.getData());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.streamx.cli.commands.settings.eventtemplates.RegisteredTemplateIdCompletionCandidates;
import com.streamx.cli.commands.settings.eventtemplates.TemplateIdCompletionCandidates;
import com.streamx.cli.config.ContextNameCompletionCandidates;
import com.streamx.cli.platform.TokenIdCompletionCandidates;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
Expand Down Expand Up @@ -215,6 +216,9 @@ private static String getCompletionAction(
if (completionCandidates instanceof ContextNameCompletionCandidates) {
return "($(streamx __complete-context-names 2>/dev/null))";
}
if (completionCandidates instanceof TokenIdCompletionCandidates) {
return "($(streamx __complete-token-ids 2>/dev/null))";
}
// Any remaining candidates are a fixed list (e.g. roles); the dynamic ones are handled above.
if (completionCandidates != null) {
String values = renderCandidates(completionCandidates);
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/streamx/cli/i18n/MessageProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public interface MessageProvider {
+ "Unset %s and run 'streamx auth login' first.")
String authTokenNeedsLoginSession(String variableName);

@Message(id = 435,
value = "Invalid expiry '%s'. Use a positive duration such as 20m, 2h or 30d.")
String authTokenInvalidExpiry(String value);

@Message(id = 101, value = "Try '%s%s' for more information on the available options%n")
String tryForMoreInformationOnAvailableOptions(
String qualifiedCommandName,
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/com/streamx/cli/platform/ProfileTokensApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.streamx.cli.platform;

import com.streamx.cli.platform.generated.api.PersonalAccessTokenResourceApi;
import com.streamx.cli.platform.generated.model.CreatePersonalAccessTokenRequest;
import com.streamx.cli.platform.generated.model.PersonalAccessTokenResponse;
import com.streamx.cli.platform.generated.model.PersonalAccessTokenSummary;
import java.time.Duration;
import java.util.List;

public class ProfileTokensApi {

private final PlatformClients clients;
private final PersonalAccessTokenResourceApi api;

public ProfileTokensApi(PlatformClients clients) {
this.clients = clients;
this.api = clients.api(PersonalAccessTokenResourceApi.class);
}

public PersonalAccessTokenResponse create(String name, Duration expiresIn) {
String lifetime = expiresIn == null ? null : expiresIn.toString();
return clients.call(() -> api.create(
new CreatePersonalAccessTokenRequest().name(name).expiresIn(lifetime),
null, null), PersonalAccessTokenResponse.class);
}

public List<PersonalAccessTokenSummary> list() {
return clients.callList(() -> api.callList(null, null), PersonalAccessTokenSummary.class);
}

public void revoke(String id) {
clients.call(() -> api.delete(id, null, null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.streamx.cli.platform;

import java.util.Collections;
import java.util.Iterator;

public class TokenIdCompletionCandidates implements Iterable<String> {
@Override
public Iterator<String> iterator() {
return Collections.emptyIterator();
}
}
Loading
Loading