From 90642bb0b122a938f7da768b776e48f0e64e59ec Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:47:18 +0200 Subject: [PATCH 1/5] feat(info): add 'tw info versions' to list launchable Nextflow versions --nextflow-version must name a version Platform knows, but tw had no way to list them. Platform API 1.190.0 added GET /platform/versions, which returns the configured Nextflow versions and the default one, as the launch form uses them. It lives under 'tw info' because it describes the Platform deployment rather than a workspace resource: 'tw launch' takes a positional pipeline name a subcommand would shadow, and 'tw pipelines versions' already means pipeline versions. The --nextflow-version help now points at it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/AbstractApiCmd.java | 6 +++ .../io/seqera/tower/cli/commands/InfoCmd.java | 6 ++- .../tower/cli/commands/info/VersionsCmd.java | 45 ++++++++++++++++ .../pipelines/NullableLaunchOptions.java | 2 +- .../cli/responses/ComponentVersions.java | 52 +++++++++++++++++++ .../java/io/seqera/tower/cli/InfoCmdTest.java | 29 +++++++++++ 6 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index 5f1221e41..46d9e4557 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -30,6 +30,7 @@ import io.seqera.tower.api.IdentitiesApi; import io.seqera.tower.api.LabelsApi; import io.seqera.tower.api.LaunchApi; +import io.seqera.tower.api.NextflowApi; import io.seqera.tower.api.OrgsApi; import io.seqera.tower.api.PipelineSchemasApi; import io.seqera.tower.api.PipelineSecretsApi; @@ -131,6 +132,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private IdentitiesApi identitiesApi; private LabelsApi labelsApi; private LaunchApi launchApi; + private NextflowApi nextflowApi; private OrgsApi orgsApi; private PipelinesApi pipelinesApi; private PipelineSchemasApi pipelineSchemasApi; @@ -245,6 +247,10 @@ protected LaunchApi launchApi() throws ApiException { return launchApi == null ? new LaunchApi(apiClient()) : launchApi; } + protected NextflowApi nextflowApi() throws ApiException { + return nextflowApi == null ? new NextflowApi(apiClient()) : nextflowApi; + } + protected OrgsApi orgsApi() throws ApiException { return orgsApi == null ? new OrgsApi(apiClient()) : orgsApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java b/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java index 5b5ab3bb7..fece79852 100644 --- a/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/InfoCmd.java @@ -17,6 +17,7 @@ package io.seqera.tower.cli.commands; import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.info.VersionsCmd; import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.InfoResponse; import io.seqera.tower.cli.responses.Response; @@ -32,7 +33,10 @@ @CommandLine.Command( name = "info", - description = "Show system info and health status" + description = "Show system info and health status", + subcommands = { + VersionsCmd.class, + } ) public class InfoCmd extends AbstractRootCmd { @Override diff --git a/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java b/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java new file mode 100644 index 000000000..a0778ccdc --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.info; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.responses.ComponentVersions; +import io.seqera.tower.cli.responses.ComponentVersions.ComponentVersion; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.NextflowVersion; +import picocli.CommandLine.Command; + +import java.util.List; +import java.util.Objects; + +@Command( + name = "versions", + description = "List the Nextflow versions available to launch with" +) +public class VersionsCmd extends AbstractApiCmd { + + private static final String NEXTFLOW = "nextflow"; + + @Override + protected Response exec() throws ApiException { + List versions = Objects.requireNonNullElse(nextflowApi().nextflowVersions().getNextflowVersions(), List.of()).stream() + .map(v -> new ComponentVersion(v.getVersion(), Boolean.TRUE.equals(v.getDefault()))) + .toList(); + return new ComponentVersions(NEXTFLOW, versions); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java b/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java index 8d114c9d9..dc2c78c23 100644 --- a/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java +++ b/src/main/java/io/seqera/tower/cli/commands/pipelines/NullableLaunchOptions.java @@ -40,7 +40,7 @@ public class NullableLaunchOptions { @Option(names = {"--syntax-parser"}, description = "Nextflow language syntax parser version: 'v1' (legacy) or 'v2'. Takes precedence over the value stored in the launch configuration.") public SyntaxParserEnum syntaxParser; - @Option(names = {"--nextflow-version"}, description = "Nextflow version to run the workflow with. Must exist in the Platform version catalog and meet the minimum required by the compute environment. Takes precedence over the value stored in the launch configuration.") + @Option(names = {"--nextflow-version"}, description = "Nextflow version to run the workflow with. Must exist in the Platform version catalog and meet the minimum required by the compute environment (see 'tw info versions'). Takes precedence over the value stored in the launch configuration.") public String nextflowVersion; @Option(names = {"--output-dir"}, description = "Per-run output directory, passed to Nextflow as '-output-dir'. Requires Nextflow 24.10.0 or later and the workflow outputs syntax. Takes precedence over the value stored in the launch configuration.") diff --git a/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java b/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java new file mode 100644 index 000000000..4c27c8af5 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/ComponentVersions.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package io.seqera.tower.cli.responses; + +import io.seqera.tower.cli.utils.TableList; + +import java.io.PrintWriter; +import java.util.List; + +public class ComponentVersions extends Response { + + public record ComponentVersion(String version, boolean isDefault) { + } + + public final String component; + public final List versions; + + public ComponentVersions(String component, List versions) { + this.component = component; + this.versions = versions; + } + + @Override + public void toString(PrintWriter out) { + out.println(ansi(String.format("%n @|bold Available %s versions:|@%n", component))); + if (versions.isEmpty()) { + out.println(ansi(String.format(" @|yellow No %s versions found|@", component))); + return; + } + + TableList table = new TableList(out, 2, "Version", "Default"); + table.setPrefix(" "); + versions.forEach(v -> table.addRow(v.version(), v.isDefault() ? "yes" : "no")); + table.print(); + out.println(""); + } +} diff --git a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java index 40f02332d..39de6d49e 100644 --- a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java @@ -17,6 +17,8 @@ package io.seqera.tower.cli; import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.responses.ComponentVersions; +import io.seqera.tower.cli.responses.ComponentVersions.ComponentVersion; import io.seqera.tower.cli.responses.InfoResponse; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -26,11 +28,13 @@ import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Properties; import static org.apache.commons.lang3.StringUtils.chop; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockserver.matchers.Times.exactly; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; @@ -156,6 +160,31 @@ void testInfoStatusUrlFail(MockServerClient mock) throws IOException { assertEquals(chop(new InfoResponse(0,-1,-1, opts).toString()), out.stdOut); } + @Test + void testVersions(MockServerClient mock) { + mock.reset(); + mock.when( + request().withMethod("GET").withPath("/platform/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "nextflowVersions": [ + {"version": "25.10.1", "image": "nf-launcher:j21-25.10.1"}, + {"version": "26.04.6", "image": "nf-launcher:j21-26.04.6", "default": true} + ] + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "info", "versions"); + + assertEquals("", out.stdErr); + assertEquals(0, out.exitCode); + assertEquals(chop(new ComponentVersions("nextflow", List.of( + new ComponentVersion("25.10.1", false), + new ComponentVersion("26.04.6", true))).toString()), out.stdOut); + assertTrue(out.stdOut.contains("26.04.6"), out.stdOut); + } + private String getCliVersion() throws IOException { Properties props = new Properties(); props.load(this.getClass().getResourceAsStream("/META-INF/build-info.properties")); From c356ef30d74974c8a0363a9438a20a89847afab9 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:47:18 +0200 Subject: [PATCH 2/5] feat(studios): add star and unstar Platform API 1.190.0 added studio starring (POST/DELETE /studios/{sessionId}/star) and the starred flag on studio describe, but tw never wired them up. This adds 'tw studios star|unstar' so CLI users can do what the UI already does. 'studios view' now shows the starred flag so the result of star/unstar is visible. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../seqera/tower/cli/commands/StudiosCmd.java | 4 ++ .../tower/cli/commands/studios/StarCmd.java | 46 ++++++++++++++++ .../tower/cli/commands/studios/UnstarCmd.java | 46 ++++++++++++++++ .../cli/responses/studios/StudioStarred.java | 40 ++++++++++++++ .../cli/responses/studios/StudiosView.java | 1 + .../tower/cli/studios/StudiosCmdTest.java | 53 +++++++++++++++++++ 6 files changed, 190 insertions(+) create mode 100644 src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java create mode 100644 src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java diff --git a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java index 345d7d48f..8cbb28b0f 100644 --- a/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/StudiosCmd.java @@ -21,6 +21,8 @@ import io.seqera.tower.cli.commands.studios.DeleteCmd; import io.seqera.tower.cli.commands.studios.ExtendCmd; import io.seqera.tower.cli.commands.studios.RenameCheckpointCmd; +import io.seqera.tower.cli.commands.studios.StarCmd; +import io.seqera.tower.cli.commands.studios.UnstarCmd; import io.seqera.tower.cli.commands.studios.ListCmd; import io.seqera.tower.cli.commands.studios.AddAsNewCmd; import io.seqera.tower.cli.commands.studios.StartCmd; @@ -45,6 +47,8 @@ StopCmd.class, UpdateCmd.class, DeleteCmd.class, + StarCmd.class, + UnstarCmd.class, ExtendCmd.class, } ) diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java new file mode 100644 index 000000000..079871b58 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/StarCmd.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "star", + description = "Add a studio to your starred studios." +) +public class StarCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + studiosApi().createDataStudioStar(sessionId, wspId); + + return new StudioStarred(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), true); + } +} diff --git a/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java b/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java new file mode 100644 index 000000000..b47d2dc06 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/commands/studios/UnstarCmd.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.commands.studios; + +import io.seqera.tower.ApiException; +import io.seqera.tower.cli.commands.global.WorkspaceOptionalOptions; +import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.cli.responses.studios.StudioStarred; +import picocli.CommandLine; + +@CommandLine.Command( + name = "unstar", + description = "Remove a studio from your starred studios." +) +public class UnstarCmd extends AbstractStudiosCmd { + + @CommandLine.Mixin + public WorkspaceOptionalOptions workspace; + + @CommandLine.Mixin + public StudioRefOptions studioRefOptions; + + @Override + protected Response exec() throws ApiException { + Long wspId = workspaceId(workspace.workspace); + String sessionId = getSessionId(studioRefOptions, wspId); + + studiosApi().deleteDataStudioStar(sessionId, wspId); + + return new StudioStarred(studioRefOptions.getStudioIdentifier(), workspaceRef(wspId), false); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java new file mode 100644 index 000000000..2a8ed4e23 --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudioStarred.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026, Seqera. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.seqera.tower.cli.responses.studios; + +import io.seqera.tower.cli.responses.Response; + +public class StudioStarred extends Response { + + public final String userSuppliedStudioIdentifier; + public final String workspaceRef; + public final boolean starred; + + public StudioStarred(String userSuppliedStudioIdentifier, String workspaceRef, boolean starred) { + this.userSuppliedStudioIdentifier = userSuppliedStudioIdentifier; + this.workspaceRef = workspaceRef; + this.starred = starred; + } + + @Override + public String toString() { + if (starred) { + return ansi(String.format("%n @|yellow Studio %s starred at %s workspace.|@%n", userSuppliedStudioIdentifier, workspaceRef)); + } + return ansi(String.format("%n @|yellow Studio %s unstarred at %s workspace.|@%n", userSuppliedStudioIdentifier, workspaceRef)); + } +} diff --git a/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java b/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java index a74ff62f2..3af4b1464 100644 --- a/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java +++ b/src/main/java/io/seqera/tower/cli/responses/studios/StudiosView.java @@ -74,6 +74,7 @@ public void toString(PrintWriter out){ table.addRow("Memory allocated", config == null ? "-" : String.valueOf(config.getMemory())); table.addRow("Build reports", studio.getWaveBuildUrl() == null ? "NA" : studio.getWaveBuildUrl()); table.addRow("Private", studio.getIsPrivate() == null ? "NA" : String.valueOf(studio.getIsPrivate())); + table.addRow("Starred", studio.getStarred() == null ? "NA" : String.valueOf(studio.getStarred())); table.addRow("Lifespan (hours)", studio.getEffectiveLifespanHours() == null ? "Unlimited" : String.valueOf(studio.getEffectiveLifespanHours())); table.addRow("SSH enabled", config == null || config.getSshEnabled() == null ? "false" : String.valueOf(config.getSshEnabled())); diff --git a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java index 0904fac17..ec0458d49 100644 --- a/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/studios/StudiosCmdTest.java @@ -32,6 +32,7 @@ import io.seqera.tower.cli.responses.studios.StudioDeleted; import io.seqera.tower.cli.responses.studios.StudioCheckpointUpdated; import io.seqera.tower.cli.responses.studios.StudioLifespanExtended; +import io.seqera.tower.cli.responses.studios.StudioStarred; import io.seqera.tower.cli.responses.studios.StudioStartSubmitted; import io.seqera.tower.cli.responses.studios.StudioStopSubmitted; import io.seqera.tower.cli.responses.studios.StudioUpdated; @@ -2867,6 +2868,58 @@ private void mockWorkspace(MockServerClient mock) { ); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testStar(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("POST").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"sessionId\": \"3e8370e7\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "studios", "star", "-w", "75887156211589", "-i", "3e8370e7"); + + assertOutput(format, out, new StudioStarred("3e8370e7", "[organization1 / workspace1]", true)); + mock.verify(request().withMethod("POST").withPath("/studios/3e8370e7/star"), VerificationTimes.once()); + } + + @ParameterizedTest + @EnumSource(OutputType.class) + void testUnstarByName(OutputType format, MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("GET").withPath("/studios").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("studios/studios_list_response")).withContentType(MediaType.APPLICATION_JSON) + ); + mock.when( + request().withMethod("DELETE").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(204) + ); + + ExecOut out = exec(format, mock, "studios", "unstar", "-w", "75887156211589", "-n", "studio-a66d"); + + assertOutput(format, out, new StudioStarred("studio-a66d", "[organization1 / workspace1]", false)); + mock.verify(request().withMethod("DELETE").withPath("/studios/3e8370e7/star"), VerificationTimes.once()); + } + + @Test + void testUnstarNotStarred(MockServerClient mock) { + mockWorkspace(mock); + mock.when( + request().withMethod("DELETE").withPath("/studios/3e8370e7/star").withQueryStringParameter("workspaceId", "75887156211589"), exactly(1) + ).respond( + response().withStatusCode(404).withBody("{\"message\": \"Studio '3e8370e7' is not starred\"}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "studios", "unstar", "-w", "75887156211589", "-i", "3e8370e7"); + + assertEquals(1, out.exitCode); + assertTrue(out.stdErr.contains("Studio '3e8370e7' is not starred"), out.stdErr); + } + @ParameterizedTest @EnumSource(OutputType.class) void testExtend(OutputType format, MockServerClient mock) { From b99341f4f57d311af9f01a8fc096e3c314d751e4 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:48:15 +0200 Subject: [PATCH 3/5] feat(info): list Fusion versions from the compatibility catalog in 'tw info versions' --fusion-version must name a version Platform knows, but 'tw info versions' only listed the Nextflow versions configured in GET /platform/versions (API 1.190.0). API 1.230.0 (COMP-2121) added GET /catalog/components/{key}/versions: the catalog's non-yanked versions of one component with its default, optionally constrained by compatibility with other components. It only exists when the catalog is enabled, which /platform/versions reports as catalogEnabled. The command now mirrors the launch form: it reads /platform/versions, and when the catalog is on it lists the requested component (-c nextflow|fusion) from the catalog, constrained to this Platform release (the form passes the same platform context). --nextflow/--fusion narrow the list to versions compatible with the given ones, e.g. the Fusion versions that work with a Nextflow pick. Without the catalog only Nextflow versions exist, so asking for another component or a compatibility filter fails instead of returning a list that silently ignores the request. The aggregated /catalog/versions is not used: across all components it also returns platform, wave and sched, which have no launch option. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tower/cli/commands/AbstractApiCmd.java | 6 ++ .../tower/cli/commands/info/VersionsCmd.java | 39 ++++++++++-- .../java/io/seqera/tower/cli/InfoCmdTest.java | 61 ++++++++++++++++++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java index 46d9e4557..41de2bb10 100644 --- a/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/AbstractApiCmd.java @@ -22,6 +22,7 @@ import io.seqera.tower.Pair; import io.seqera.tower.api.ActionsApi; import io.seqera.tower.api.AvatarsApi; +import io.seqera.tower.api.CompatibilityApi; import io.seqera.tower.api.ComputeEnvsApi; import io.seqera.tower.api.CredentialsApi; import io.seqera.tower.api.DataLinksApi; @@ -124,6 +125,7 @@ public abstract class AbstractApiCmd extends AbstractCmd { private ActionsApi actionsApi; private AvatarsApi avatarsApi; + private CompatibilityApi compatibilityApi; private ComputeEnvsApi computeEnvsApi; private CredentialsApi credentialsApi; private DataLinksApi dataLinksApi; @@ -215,6 +217,10 @@ protected AvatarsApi avatarsApi() throws ApiException { return avatarsApi == null ? new AvatarsApi(apiClient()) : avatarsApi; } + protected CompatibilityApi compatibilityApi() throws ApiException { + return compatibilityApi == null ? new CompatibilityApi(apiClient()) : compatibilityApi; + } + protected ComputeEnvsApi computeEnvsApi() throws ApiException { return computeEnvsApi == null ? new ComputeEnvsApi(apiClient()) : computeEnvsApi; } diff --git a/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java b/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java index a0778ccdc..0b1f374c8 100644 --- a/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java +++ b/src/main/java/io/seqera/tower/cli/commands/info/VersionsCmd.java @@ -14,32 +14,63 @@ * limitations under the License. */ + package io.seqera.tower.cli.commands.info; import io.seqera.tower.ApiException; import io.seqera.tower.cli.commands.AbstractApiCmd; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.ComponentVersions; import io.seqera.tower.cli.responses.ComponentVersions.ComponentVersion; import io.seqera.tower.cli.responses.Response; +import io.seqera.tower.model.CatalogVersion; +import io.seqera.tower.model.CatalogVersionsResponse; import io.seqera.tower.model.NextflowVersion; +import io.seqera.tower.model.NextflowVersionsResponse; import picocli.CommandLine.Command; +import picocli.CommandLine.Option; import java.util.List; import java.util.Objects; @Command( name = "versions", - description = "List the Nextflow versions available to launch with" + description = "List the Nextflow or Fusion versions available to launch with" ) public class VersionsCmd extends AbstractApiCmd { private static final String NEXTFLOW = "nextflow"; + @Option(names = {"-c", "--component"}, description = "Component to list versions of: 'nextflow' or 'fusion'. Default: nextflow.", defaultValue = NEXTFLOW) + public String component; + + @Option(names = {"--nextflow"}, description = "Only list versions compatible with these Nextflow versions. Comma-separated list.") + public String nextflow; + + @Option(names = {"--fusion"}, description = "Only list versions compatible with these Fusion versions. Comma-separated list.") + public String fusion; + @Override protected Response exec() throws ApiException { - List versions = Objects.requireNonNullElse(nextflowApi().nextflowVersions().getNextflowVersions(), List.of()).stream() - .map(v -> new ComponentVersion(v.getVersion(), Boolean.TRUE.equals(v.getDefault()))) + NextflowVersionsResponse platformVersions = nextflowApi().nextflowVersions(); + + if (!Boolean.TRUE.equals(platformVersions.getCatalogEnabled())) { + // Without the compatibility catalog, Platform only knows its configured Nextflow versions. + if (!NEXTFLOW.equals(component) || nextflow != null || fusion != null) { + throw new TowerException("The component compatibility catalog is not enabled on this Platform: only Nextflow versions can be listed, without compatibility filters"); + } + List versions = Objects.requireNonNullElse(platformVersions.getNextflowVersions(), List.of()).stream() + .map(v -> new ComponentVersion(v.getVersion(), Boolean.TRUE.equals(v.getDefault()))) + .toList(); + return new ComponentVersions(component, versions); + } + + // Constrain the catalog to this Platform release, as the launch form does. + String platform = serviceInfoApi().info().getServiceInfo().getVersion(); + CatalogVersionsResponse catalog = compatibilityApi().catalogComponentVersions(component, platform, nextflow, fusion, null, null); + List versions = Objects.requireNonNullElse(catalog.getVersions(), List.of()).stream() + .map(v -> new ComponentVersion(v.getVersion(), v.getVersion().equals(catalog.getDefaultVersion()))) .toList(); - return new ComponentVersions(NEXTFLOW, versions); + return new ComponentVersions(component, versions); } } diff --git a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java index 39de6d49e..93c699ff1 100644 --- a/src/test/java/io/seqera/tower/cli/InfoCmdTest.java +++ b/src/test/java/io/seqera/tower/cli/InfoCmdTest.java @@ -17,6 +17,7 @@ package io.seqera.tower.cli; import io.seqera.tower.cli.commands.enums.OutputType; +import io.seqera.tower.cli.exceptions.TowerException; import io.seqera.tower.cli.responses.ComponentVersions; import io.seqera.tower.cli.responses.ComponentVersions.ComponentVersion; import io.seqera.tower.cli.responses.InfoResponse; @@ -160,14 +161,57 @@ void testInfoStatusUrlFail(MockServerClient mock) throws IOException { assertEquals(chop(new InfoResponse(0,-1,-1, opts).toString()), out.stdOut); } + @ParameterizedTest + @EnumSource(OutputType.class) + void testVersionsFromCatalog(OutputType format, MockServerClient mock) { + mock.reset(); + mock.when( + request().withMethod("GET").withPath("/platform/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"catalogEnabled\":true,\"nextflowVersions\":[]}").withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/service-info"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(loadResource("info/service-info")).withContentType(MediaType.APPLICATION_JSON) + ); + + mock.when( + request().withMethod("GET").withPath("/catalog/components/fusion/versions") + .withQueryStringParameter("platform", "22.3.0-torricelli") + .withQueryStringParameter("nextflow", "26.04"), exactly(1) + ).respond( + response().withStatusCode(200).withBody(""" + { + "versions": [ + {"component": "fusion", "version": "2.5", "origin": "published", "yanked": false}, + {"component": "fusion", "version": "2.6", "origin": "published", "yanked": false} + ], + "defaultVersion": "2.6", + "deploymentVersions": {"platform": "22.3.0-torricelli"} + }""").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(format, mock, "info", "versions", "-c", "fusion", "--nextflow", "26.04"); + + assertOutput(format, out, new ComponentVersions("fusion", List.of( + new ComponentVersion("2.5", false), + new ComponentVersion("2.6", true)))); + if (format == OutputType.json) { + assertTrue(out.stdOut.replaceAll("\\s", "").contains("{\"version\":\"2.6\",\"isDefault\":true}"), out.stdOut); + } + } + @Test - void testVersions(MockServerClient mock) { + void testVersionsWithoutCatalog(MockServerClient mock) { mock.reset(); mock.when( request().withMethod("GET").withPath("/platform/versions"), exactly(1) ).respond( response().withStatusCode(200).withBody(""" { + "catalogEnabled": false, "nextflowVersions": [ {"version": "25.10.1", "image": "nf-launcher:j21-25.10.1"}, {"version": "26.04.6", "image": "nf-launcher:j21-26.04.6", "default": true} @@ -185,6 +229,21 @@ void testVersions(MockServerClient mock) { assertTrue(out.stdOut.contains("26.04.6"), out.stdOut); } + @Test + void testVersionsFusionWithoutCatalog(MockServerClient mock) { + mock.reset(); + mock.when( + request().withMethod("GET").withPath("/platform/versions"), exactly(1) + ).respond( + response().withStatusCode(200).withBody("{\"catalogEnabled\":false}").withContentType(MediaType.APPLICATION_JSON) + ); + + ExecOut out = exec(mock, "info", "versions", "-c", "fusion"); + + assertEquals(errorMessage(out.app, new TowerException("The component compatibility catalog is not enabled on this Platform: only Nextflow versions can be listed, without compatibility filters")), out.stdErr); + assertEquals(1, out.exitCode); + } + private String getCliVersion() throws IOException { Properties props = new Properties(); props.load(this.getClass().getResourceAsStream("/META-INF/build-info.properties")); From a499b799eac31b95d2e35b13fae8ff8a6814b13c Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Mon, 5 Oct 2026 17:47:32 +0200 Subject: [PATCH 4/5] docs: document 'tw info versions' and studio stars 'Other additions' entries for 'tw info versions' (Nextflow and Fusion versions) and 'tw studios star|unstar'. Co-Authored-By: Claude Opus 5.5 (1M context) --- USAGE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/USAGE.md b/USAGE.md index 84e08f161..c7ac488a1 100644 --- a/USAGE.md +++ b/USAGE.md @@ -762,10 +762,10 @@ tw managed-identities credentials -o my-org -n hpc1 add -l my_linux_user -k ~/.s - AWS Cloud: `--log-group`. Google Cloud: `--project-id`. - HPC platforms: `--propagate-head-job-options`. - Seqera Compute: `--data-retention-policy`. -- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. +- `tw launch`, `tw pipelines add|update`, `tw runs relaunch`, and `tw actions add|update`: `--tower-config`. List the Nextflow and Fusion versions you can launch with `tw info versions` (`-c fusion` for Fusion). - `tw runs star|unstar`, `tw runs view log [--next ]`, and `tw runs delete -i ,`. - `tw pipelines schema`: show a pipeline's parameter schema. -- `tw studios extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. +- `tw studios star|unstar|extend|rename-checkpoint`. `--mount-data-uris` also accepts a folder inside a data link. - `tw data-links delete-content|download-script`. - `tw datasets add --url`, `tw datasets update --url`, and `tw datasets disable-version`. From 613eeda2c3acfa227a41b01faf94347a963d7d59 Mon Sep 17 00:00:00 2001 From: Cristian Ramon-Cortes Date: Wed, 7 Oct 2026 16:59:39 +0200 Subject: [PATCH 5/5] build(native): register reflection metadata for 'info versions' and studio stars Entries for the 'info versions' and 'studios star|unstar' commands and responses, taken from a full test run under the GraalVM tracing agent. Response classes register allDeclaredFields, since Jackson serializes every field for -o json. Co-Authored-By: Claude Opus 5.5 (1M context) --- conf/reflect-config.json | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/conf/reflect-config.json b/conf/reflect-config.json index a162cd754..2cc75b0bd 100644 --- a/conf/reflect-config.json +++ b/conf/reflect-config.json @@ -1431,6 +1431,13 @@ "allDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.info.VersionsCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"component"}, {"name":"fusion"}, {"name":"nextflow"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.labels.AbstractLabelsCmd", "allDeclaredFields":true, @@ -2183,6 +2190,13 @@ "fields":[{"name":"checkpointId"}, {"name":"newName"}, {"name":"studioRefOptions"}, {"name":"workspace"}], "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.StarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.StartCmd", "allDeclaredFields":true, @@ -2250,6 +2264,13 @@ "queryAllDeclaredMethods":true, "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"io.seqera.tower.cli.commands.studios.UnstarCmd", + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "fields":[{"name":"studioRefOptions"}, {"name":"workspace"}], + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.commands.studios.UpdateCmd", "allDeclaredFields":true, @@ -2426,6 +2447,19 @@ "allDeclaredConstructors":true, "methods":[{"name":"","parameterTypes":[] }, {"name":"setMessage","parameterTypes":["java.lang.String"] }] }, +{ + "name":"io.seqera.tower.cli.responses.ComponentVersions", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, +{ + "name":"io.seqera.tower.cli.responses.ComponentVersions$ComponentVersion", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true, + "methods":[{"name":"isDefault","parameterTypes":[] }, {"name":"version","parameterTypes":[] }] +}, { "name":"io.seqera.tower.cli.responses.CredentialsAdded", "allDeclaredFields":true, @@ -3176,6 +3210,12 @@ "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true }, +{ + "name":"io.seqera.tower.cli.responses.studios.StudioStarred", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "queryAllDeclaredConstructors":true +}, { "name":"io.seqera.tower.cli.responses.studios.StudioStartSubmitted", "allDeclaredFields":true,