Skip to content
Merged
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ Commands are organized into three product groups — `floci aws` (or bare `floci
| `floci config default-product` | Set the default product (aws, gcp, or az) |
| `floci update` | Self-update the CLI to the latest release |
| `floci completion bash\|zsh` | Generate shell completion scripts |
| `floci update` | Update the CLI to the latest release |

### AWS commands (`floci` / `floci aws`)

Expand Down Expand Up @@ -504,6 +505,24 @@ floci completion bash >> ~/.bashrc
floci completion zsh >> ~/.zshrc
```

### `floci update`

Self-update the CLI in place: downloads the release binary from GitHub, verifies its
sha256 against the release's `sha256sums.txt`, and atomically replaces the running binary.

```sh
floci update # update to the latest release
floci update --check # exit 0: up to date, exit 1: update available
floci update --version 0.1.7 # install a specific version
```

Homebrew installs are managed by brew and are detected and refused — use `brew upgrade floci` there instead.

When run from an interactive terminal, floci also checks for new releases in the
background (at most once per 24h, cached in `~/.floci/update-check.json`) and prints a
hint before the command output when one is available. Set `FLOCI_NO_UPDATE_CHECK=1` to
opt out; the check is automatically disabled in CI and for piped output.

---

## CI Usage
Expand Down
31 changes: 30 additions & 1 deletion src/main/java/io/floci/cli/FlociCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.floci.cli.commands.snapshot.SnapshotCommand;
import io.floci.cli.config.GlobalConfigStore;
import io.floci.cli.output.Ansi;
import io.floci.cli.update.UpdateNotifier;
import picocli.CommandLine;
import picocli.CommandLine.*;

Expand All @@ -21,7 +22,8 @@
" floci env — print environment variables%n" +
" floci aws — explicit AWS emulator commands%n" +
" floci az — Azure emulator commands%n" +
" floci gcp — GCP emulator commands%n",
" floci gcp — GCP emulator commands%n" +
" floci update — Update the CLI to the latest release",
mixinStandardHelpOptions = true,
versionProvider = FlociCli.VersionProvider.class,
subcommands = {
Expand Down Expand Up @@ -82,10 +84,29 @@ public static void main(String[] args) {
}
}

// npm/gh-style update hint: announce a newer version (from the local cache — no
// network) before the command runs, and refresh that cache in the background at
// most once per 24h. Skipped for scripts/CI (non-interactive) and for 'update'
// itself, which already talks about versions.
boolean notify = UpdateNotifier.interactiveRunEnabled() && !isUpdateCommand(effectiveArgs);
if (notify) {
UpdateNotifier.pendingNotice(VersionCommand.CLI_VERSION).ifPresent(latest ->
System.err.println(Ansi.yellow("A new release of floci is available: "
+ VersionCommand.CLI_VERSION + " → " + latest + "\n"
+ "Run 'floci update' to install it") + "\n"));
UpdateNotifier.refreshInBackground();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

int exitCode = new CommandLine(new FlociCli())
.setExecutionExceptionHandler(new ExceptionHandler())
.setCaseInsensitiveEnumValuesAllowed(true)
.execute(effectiveArgs);

if (notify) {
// Fast commands would otherwise exit before the background check persists
// the cache for the next run.
UpdateNotifier.awaitRefresh(java.time.Duration.ofMillis(250));
}
System.exit(exitCode);
}

Expand All @@ -100,6 +121,14 @@ private static boolean isExplicitProduct(String[] args) {
return false;
}

private static boolean isUpdateCommand(String[] args) {
for (String arg : args) {
if (arg.startsWith("-")) continue;
return "update".equals(arg);
}
return false;
}

private static String[] prepend(String token, String[] args) {
String[] result = new String[args.length + 1];
result[0] = token;
Expand Down
18 changes: 14 additions & 4 deletions src/main/java/io/floci/cli/commands/UpdateCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import io.floci.cli.output.OutputFormat;
import io.floci.cli.output.Printer;
import io.floci.cli.update.ReleaseChannel;
import io.floci.cli.update.Version;
import picocli.CommandLine.*;

import java.io.BufferedInputStream;
Expand Down Expand Up @@ -75,7 +76,10 @@ public Integer call() {
boolean pinned = to != null && !to.isBlank();
String target = pinned ? to.trim() : fetchLatestVersion();

if (target.equals(current)) {
// Unpinned updates are semantic, not string-equal: a pre-release/snapshot build
// (0.1.9-rc.1) must not be silently downgraded to the latest stable (0.1.8).
// Pinned --version keeps exact equality so deliberate downgrades still work.
if (pinned ? target.equals(current) : !Version.isNewer(target, current)) {
printer.println(Ansi.green("✓") + " floci " + current + " is up to date");
return 0;
}
Expand Down Expand Up @@ -123,8 +127,10 @@ private String fetchLatestVersion() {
try {
JsonNode release = JSON.readTree(fetchString(ReleaseChannel.latestReleaseUrl()));
String tag = release.path("tag_name").asText("");
if (tag.isBlank()) {
throw new IOException("no tag_name in the GitHub API response");
// Same shape-check as UpdateNotifier: a captive portal or proxy error page must
// not end up spliced into a download URL.
if (!Version.isValid(tag)) {
throw new IOException("GitHub API response did not contain a release version");
}
return tag;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} catch (InterruptedException e) {
Expand Down Expand Up @@ -214,9 +220,13 @@ static String detectPlatform() {

private String fetchString(String url) throws IOException, InterruptedException {
HttpResponse<String> resp = http.send(get(url), HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200 || resp.body().isBlank()) {
if (resp.statusCode() != 200) {
throw new IOException("GET " + url + " → HTTP " + resp.statusCode());
}
if (resp.body().isBlank()) {
// GitHub's CDN can intermittently return 200 with an empty body (see install.sh).
throw new IOException("GET " + url + " returned an empty body — please retry");
}
return resp.body();
}

Expand Down
5 changes: 5 additions & 0 deletions src/main/java/io/floci/cli/update/UpdateCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.floci.cli.update;

/** Persisted shape of {@code ~/.floci/update-check.json}. */
public record UpdateCache(long checkedAtEpochSeconds, String latestVersion) {
}
165 changes: 165 additions & 0 deletions src/main/java/io/floci/cli/update/UpdateNotifier.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package io.floci.cli.update;

import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.Console;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;

/**
* Backing logic for the "a new version is available" hint, in the style of npm/gh/rustup.
* Split in two halves so it never blocks or spams:
* <ul>
* <li>{@link #pendingNotice} reads the last known latest version from a local cache
* ({@code ~/.floci/update-check.json}) — the end-of-command hint renders from this,
* no network.</li>
* <li>{@link #refreshInBackground} runs the actual check on a background virtual thread
* (fail-silent), only when the cache is older than 24h, updating it for next time.</li>
* </ul>
*/
public final class UpdateNotifier {

static final Duration TTL = Duration.ofHours(24);

private static final ObjectMapper JSON = new ObjectMapper();

/** The in-flight refresh, if this run started one; lets main() grant it a short grace. */
private static final AtomicReference<Thread> REFRESH = new AtomicReference<>();

private UpdateNotifier() {
}

/**
* The newer version to announce at end-of-command, if any: cached latest strictly newer
* than {@code currentVersion}. Pure cache read — the caller gates on
* {@link #interactiveRunEnabled()}.
*/
public static Optional<String> pendingNotice(String currentVersion) {
if (currentVersion == null) {
return Optional.empty();
}
return cachedLatest().filter(latest -> Version.isNewer(latest, currentVersion));
}

/** The latest version recorded by the last successful check, if any. Never fails. */
public static Optional<String> cachedLatest() {
try {
Path path = cachePath();
if (!Files.isRegularFile(path)) {
return Optional.empty();
}
return Optional.ofNullable(JSON.readValue(path.toFile(), UpdateCache.class).latestVersion());
} catch (Exception _) {
return Optional.empty();
}
}

/** Kick a background refresh if the cache is stale/absent. Non-blocking and fail-silent;
* the caller gates on {@link #interactiveRunEnabled()}. */
public static void refreshInBackground() {
refreshInBackground(ReleaseChannel.latestReleaseUrl());
}

/** Refresh from an explicit URL (the seam used by tests). */
static void refreshInBackground(String latestReleaseUrl) {
Optional<UpdateCache> cache = readQuietly();
if (cache.isPresent() && !isStale(cache.get())) {
return;
}
// Virtual threads do not keep the JVM alive: a quick exit just drops the check and it
// is retried next time; main() grants a short grace via awaitRefresh so commands that
// finish fast still get the cache populated eventually.
REFRESH.set(Thread.ofVirtual().name("floci-update-check").start(() -> {
try {
String latest = fetchLatestVersion(latestReleaseUrl);
Path path = cachePath();
Files.createDirectories(path.getParent());
JSON.writeValue(path.toFile(),
new UpdateCache(Instant.now().getEpochSecond(), latest));
} catch (Exception _) {
// Network/parse failure — never surfaced; the stale/absent cache stays as-is.
}
}));
}

/** Give an in-flight refresh a bounded chance to finish before the JVM exits. */
public static void awaitRefresh(Duration grace) {
Thread refresh = REFRESH.get();
if (refresh != null) {
try {
refresh.join(grace);
} catch (InterruptedException _) {
Thread.currentThread().interrupt();
}
}
}

private static Optional<UpdateCache> readQuietly() {
try {
Path path = cachePath();
return Files.isRegularFile(path)
? Optional.of(JSON.readValue(path.toFile(), UpdateCache.class))
: Optional.empty();
} catch (Exception _) {
return Optional.empty();
}
}

private static boolean isStale(UpdateCache cache) {
return Instant.ofEpochSecond(cache.checkedAtEpochSeconds()).plus(TTL).isBefore(Instant.now());
}

private static String fetchLatestVersion(String latestReleaseUrl) throws Exception {
HttpClient http = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(2))
.build();
HttpRequest req = HttpRequest.newBuilder(URI.create(latestReleaseUrl))
.timeout(Duration.ofSeconds(3)).GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new IllegalStateException("HTTP " + resp.statusCode());
}
String version = JSON.readTree(resp.body()).path("tag_name").asText("");
// Shape-check before persisting: a captive portal (hotel/airport WiFi) answers any URL
// with HTTP 200 + HTML — never let that poison the cache.
if (!Version.isValid(version)) {
throw new IllegalStateException("response is not a version");
}
return version;
}

static Path cachePath() {
return Path.of(System.getProperty("user.home"), ".floci", "update-check.json");
}

/**
* Whether update UX (checks and notices) applies to this run at all: an interactive
* terminal, not CI, not opted out. Piped invocations ({@code floci ... | jq}) are excluded —
* their stdout must stay pure and even stderr noise is unwelcome in scripts.
*/
public static boolean interactiveRunEnabled() {
return !notBlank(System.getenv("FLOCI_NO_UPDATE_CHECK"))
&& !notBlank(System.getenv("CI"))
&& interactiveTerminal();
}

private static boolean interactiveTerminal() {
// Java 22+ can return a Console even with redirected streams — isTerminal() is the
// authoritative check (a null console is simply never a terminal).
Console console = System.console();
return console != null && console.isTerminal();
}

private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}
44 changes: 44 additions & 0 deletions src/main/java/io/floci/cli/update/Version.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.floci.cli.update;

import java.util.regex.Pattern;

/** Minimal semver helpers for the update check — no ranges, just "is this newer". */
public final class Version {

// x.y.z with an optional pre-release/build suffix; tolerates a leading "v".
private static final Pattern SHAPE = Pattern.compile("v?\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?");

private Version() {
}

/** Whether {@code s} looks like a release version at all (guards cache poisoning). */
public static boolean isValid(String s) {
return s != null && SHAPE.matcher(s).matches();
}

/** Numeric compare of the x.y.z core; a pre-release suffix on equal cores sorts older. */
public static boolean isNewer(String candidate, String current) {
if (!isValid(candidate) || !isValid(current)) {
return false;
}
int[] a = core(candidate);
int[] b = core(current);
for (int i = 0; i < 3; i++) {
if (a[i] != b[i]) {
return a[i] > b[i];
}
}
// Equal cores: only "1.2.3" is newer than "1.2.3-rc.1", never the reverse.
return hasSuffix(current) && !hasSuffix(candidate);
}

private static int[] core(String v) {
String stripped = v.startsWith("v") ? v.substring(1) : v;
String[] parts = stripped.split("[-+]", 2)[0].split("\\.");
return new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2])};
}

private static boolean hasSuffix(String v) {
return v.contains("-") || v.contains("+");
}
}
Loading