diff --git a/README.md b/README.md index b675def..ac251b6 100644 --- a/README.md +++ b/README.md @@ -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`) @@ -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 diff --git a/src/main/java/io/floci/cli/FlociCli.java b/src/main/java/io/floci/cli/FlociCli.java index fdad926..47364d8 100644 --- a/src/main/java/io/floci/cli/FlociCli.java +++ b/src/main/java/io/floci/cli/FlociCli.java @@ -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.*; @@ -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 = { @@ -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(); + } + 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); } @@ -95,7 +116,16 @@ private static boolean isExplicitProduct(String[] args) { for (String arg : args) { if (arg.startsWith("-")) continue; return "aws".equals(arg) || "az".equals(arg) || "gcp".equals(arg) - || "config".equals(arg) || "completion".equals(arg) || "help".equals(arg); + || "config".equals(arg) || "completion".equals(arg) || "help".equals(arg) + || "update".equals(arg); + } + return false; + } + + private static boolean isUpdateCommand(String[] args) { + for (String arg : args) { + if (arg.startsWith("-")) continue; + return "update".equals(arg); } return false; } diff --git a/src/main/java/io/floci/cli/commands/UpdateCommand.java b/src/main/java/io/floci/cli/commands/UpdateCommand.java index 65c5262..193e52b 100644 --- a/src/main/java/io/floci/cli/commands/UpdateCommand.java +++ b/src/main/java/io/floci/cli/commands/UpdateCommand.java @@ -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; @@ -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; } @@ -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; } catch (InterruptedException e) { @@ -214,9 +220,13 @@ static String detectPlatform() { private String fetchString(String url) throws IOException, InterruptedException { HttpResponse 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(); } diff --git a/src/main/java/io/floci/cli/update/UpdateCache.java b/src/main/java/io/floci/cli/update/UpdateCache.java new file mode 100644 index 0000000..1e742be --- /dev/null +++ b/src/main/java/io/floci/cli/update/UpdateCache.java @@ -0,0 +1,5 @@ +package io.floci.cli.update; + +/** Persisted shape of {@code ~/.floci/update-check.json}. */ +public record UpdateCache(long checkedAtEpochSeconds, String latestVersion) { +} diff --git a/src/main/java/io/floci/cli/update/UpdateNotifier.java b/src/main/java/io/floci/cli/update/UpdateNotifier.java new file mode 100644 index 0000000..3a85a8e --- /dev/null +++ b/src/main/java/io/floci/cli/update/UpdateNotifier.java @@ -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: + * + */ +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 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 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 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 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 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 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(); + } +} diff --git a/src/main/java/io/floci/cli/update/Version.java b/src/main/java/io/floci/cli/update/Version.java new file mode 100644 index 0000000..94b0944 --- /dev/null +++ b/src/main/java/io/floci/cli/update/Version.java @@ -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("+"); + } +}