Skip to content

Commit ca1a037

Browse files
authored
concord-cli: add self-update command (#1198)
1 parent e05ccea commit ca1a037

File tree

2 files changed

+199
-1
lines changed

2 files changed

+199
-1
lines changed

cli/src/main/java/com/walmartlabs/concord/cli/App.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import picocli.CommandLine.Option;
2626
import picocli.CommandLine.Spec;
2727

28-
@Command(name = "concord", subcommands = {Lint.class, Run.class})
28+
@Command(name = "concord", subcommands = {Lint.class, Run.class, SelfUpdate.class})
2929
public class App implements Runnable {
3030

3131
@Spec
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package com.walmartlabs.concord.cli;
2+
3+
/*-
4+
* *****
5+
* Concord
6+
* -----
7+
* Copyright (C) 2017 - 2025 Walmart Inc.
8+
* -----
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* =====
21+
*/
22+
23+
import com.fasterxml.jackson.databind.ObjectMapper;
24+
import org.apache.maven.artifact.versioning.ComparableVersion;
25+
import picocli.CommandLine;
26+
import picocli.CommandLine.Command;
27+
28+
import java.io.IOException;
29+
import java.net.URI;
30+
import java.net.http.HttpClient;
31+
import java.net.http.HttpRequest;
32+
import java.net.http.HttpResponse.BodyHandlers;
33+
import java.nio.file.*;
34+
import java.nio.file.attribute.PosixFilePermission;
35+
import java.time.Duration;
36+
import java.util.HashSet;
37+
import java.util.Optional;
38+
import java.util.concurrent.Callable;
39+
40+
import static org.fusesource.jansi.Ansi.ansi;
41+
42+
@Command(name = "self-update", description = "Update the CLI to the latest release version")
43+
public class SelfUpdate implements Callable<Integer> {
44+
45+
private static final Duration TIMEOUT = Duration.ofSeconds(10);
46+
private static final URI GITHUB_RELEASES_ENDPOINT = URI.create("https://api.github.com/repos/walmartlabs/concord/releases/latest");
47+
private static final String DOWNLOAD_TEMPLATE = "https://repo.maven.apache.org/maven2/com/walmartlabs/concord/concord-cli/%1$s/concord-cli-%1$s.sh";
48+
49+
@CommandLine.Option(names = {"-h", "--help"}, usageHelp = true, description = "display the command's help message")
50+
boolean helpRequested = false;
51+
52+
@Override
53+
public Integer call() {
54+
var selfLocation = SelfUpdate.class.getProtectionDomain().getCodeSource().getLocation();
55+
56+
Path dst;
57+
try {
58+
dst = Paths.get(selfLocation.getPath());
59+
} catch (InvalidPathException e) {
60+
return unableToDetermineSelfLocation();
61+
}
62+
63+
if (Files.isDirectory(dst)) {
64+
return unableToDetermineSelfLocation();
65+
}
66+
if (!Files.isWritable(dst)) {
67+
return selfLocationIsNotWritable();
68+
}
69+
70+
String latestVersion;
71+
try {
72+
System.out.println("Checking for updates...");
73+
var maybeLatestVersion = getLatestVersion();
74+
if (maybeLatestVersion.isEmpty()) {
75+
return unableToDetermineLatestReleaseVersion();
76+
}
77+
78+
latestVersion = maybeLatestVersion.get();
79+
} catch (IOException | InterruptedException e) {
80+
return err(e.getMessage());
81+
}
82+
83+
var currentVersion = Version.getVersion();
84+
var comparison = new ComparableVersion(latestVersion).compareTo(new ComparableVersion(currentVersion));
85+
if (comparison == 0) {
86+
return currentVersionIsLatest();
87+
} else if (comparison < 0) {
88+
return currentVersionIsMoreRecent();
89+
}
90+
91+
System.out.printf("Updating to %s...%n", latestVersion);
92+
93+
try {
94+
var tmpFile = Files.createTempFile("concord-cli-" + latestVersion, ".sh");
95+
var src = downloadArtifact(latestVersion, tmpFile);
96+
Files.move(src, dst, StandardCopyOption.REPLACE_EXISTING);
97+
} catch (IOException | InterruptedException e) {
98+
return err(e.getMessage());
99+
}
100+
101+
System.out.println(ansi().fgBrightGreen().a("Done!"));
102+
103+
try {
104+
var permissions = new HashSet<>(Files.getPosixFilePermissions(dst));
105+
permissions.add(PosixFilePermission.OWNER_EXECUTE);
106+
Files.setPosixFilePermissions(dst, permissions);
107+
} catch (UnsupportedOperationException | IOException e) {
108+
warn("Unable to mark the binary as an executable. You might need to manually update the permissions of " + dst.toAbsolutePath());
109+
}
110+
111+
return 0;
112+
}
113+
114+
private static Optional<String> getLatestVersion() throws IOException, InterruptedException {
115+
var client = HttpClient.newBuilder()
116+
.connectTimeout(TIMEOUT)
117+
.build();
118+
119+
var request = HttpRequest.newBuilder()
120+
.uri(GITHUB_RELEASES_ENDPOINT)
121+
.timeout(TIMEOUT)
122+
.header("Accept", "application/vnd.github.v3+json")
123+
.header("User-Agent", "concord-cli " + Version.getVersion())
124+
.GET()
125+
.build();
126+
127+
var response = client.send(request, BodyHandlers.ofInputStream());
128+
if (response.statusCode() == 200) {
129+
var mapper = new ObjectMapper();
130+
try (var body = response.body()) {
131+
var json = mapper.readTree(body);
132+
var tagName = json.path("tag_name").asText();
133+
if (!tagName.isEmpty()) {
134+
return Optional.of(tagName);
135+
}
136+
}
137+
} else if (response.statusCode() == 404) {
138+
throw new IOException("Repository not found or no releases available.");
139+
} else {
140+
throw new IOException("GitHub API returned unexpected status: " + response.statusCode());
141+
}
142+
return Optional.empty();
143+
}
144+
145+
private static Path downloadArtifact(String version, Path dst) throws IOException, InterruptedException {
146+
var client = HttpClient.newBuilder()
147+
.connectTimeout(TIMEOUT)
148+
.build();
149+
150+
var request = HttpRequest.newBuilder()
151+
.uri(URI.create(DOWNLOAD_TEMPLATE.formatted(version)))
152+
.timeout(TIMEOUT)
153+
.header("Accept", "application/octet-stream")
154+
.header("User-Agent", "concord-cli " + Version.getVersion())
155+
.GET()
156+
.build();
157+
158+
var response = client.send(request, BodyHandlers.ofFile(dst));
159+
if (response.statusCode() == 200) {
160+
return response.body();
161+
} else if (response.statusCode() == 404) {
162+
throw new IOException("Release %s not found".formatted(version));
163+
} else {
164+
throw new IOException("Maven Central returned unexpected status: " + response.statusCode());
165+
}
166+
}
167+
168+
private static int unableToDetermineSelfLocation() {
169+
return err("Unable to determine the location of the CLI binary, self-update is not possible.");
170+
}
171+
172+
private static int selfLocationIsNotWritable() {
173+
return err("Unable to overwrite the CLI binary, self-update is not possible.");
174+
}
175+
176+
private static int unableToDetermineLatestReleaseVersion() {
177+
return err("Cannot determine the latest release version.");
178+
}
179+
180+
private static int currentVersionIsLatest() {
181+
System.out.println("The current version is the latest release version. Nothing to do.");
182+
return 0;
183+
}
184+
185+
private static int currentVersionIsMoreRecent() {
186+
System.out.println("The current version is more recent than the latest available release version. Nothing to do.");
187+
return 0;
188+
}
189+
190+
private static int err(String msg) {
191+
System.out.println(ansi().fgRed().a(msg));
192+
return -1;
193+
}
194+
195+
private static void warn(String msg) {
196+
System.out.println(ansi().fgYellow().a(msg));
197+
}
198+
}

0 commit comments

Comments
 (0)