From b1b0f393030e9591851890ab67d2359a13f8013c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 26 Jun 2026 10:03:21 -0400 Subject: [PATCH 1/7] fix: align ContainerInfo fields with inspect format template --- .../java/io/floci/cli/docker/DockerClient.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/floci/cli/docker/DockerClient.java b/src/main/java/io/floci/cli/docker/DockerClient.java index 6c3e572..572557a 100644 --- a/src/main/java/io/floci/cli/docker/DockerClient.java +++ b/src/main/java/io/floci/cli/docker/DockerClient.java @@ -18,7 +18,6 @@ public record ContainerInfo( String id, String name, String image, - String status, String state, String ports) {} @@ -47,18 +46,19 @@ public boolean isDaemonReachable() { public Optional inspectContainer(String name) throws DockerException { try { - String out = run("docker", "inspect", "--format", - "{{.Id}}|{{.Name}}|{{.Config.Image}}|{{.State.Status}}|{{.State.Status}}|{{range $p, $b := .NetworkSettings.Ports}}{{if $b}}{{(index $b 0).HostPort}}->{{$p}} {{end}}{{end}}", + String out = run("docker", "container", "inspect", + "--format", + "{{.Id}}|{{.Name}}|{{.Config.Image}}|{{.State.Status}}|" + + "{{range $p, $b := .NetworkSettings.Ports}}{{if $b}}{{(index $b 0).HostPort}}->{{$p}} {{end}}{{end}}", name); if (out.isBlank()) return Optional.empty(); String[] parts = out.trim().split("\\|", -1); return Optional.of(new ContainerInfo( - parts.length > 0 ? parts[0] : "", + parts.length > 0 ? parts[0] : "", // id parts.length > 1 ? parts[1].replaceFirst("^/", "") : name, - parts.length > 2 ? parts[2] : "", - parts.length > 3 ? parts[3] : "", - parts.length > 4 ? parts[4] : "", - parts.length > 5 ? parts[5].trim() : "")); + parts.length > 2 ? parts[2] : "", // image + parts.length > 3 ? parts[3] : "", // status + parts.length > 4 ? parts[4].trim() : "")); // ports } catch (DockerException e) { if (e.getMessage() != null && e.getMessage().toLowerCase().contains("no such")) { return Optional.empty(); From 657b316f4da2514c7f04cc8dd9cf197b2bd5c622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 3 Jul 2026 14:24:02 -0400 Subject: [PATCH 2/7] feat(update): add self-update command with checksum verification --- src/main/java/io/floci/cli/FlociCli.java | 3 +- .../io/floci/cli/commands/UpdateCommand.java | 287 ++++++++++++++++++ .../io/floci/cli/update/ReleaseChannel.java | 32 ++ 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/floci/cli/commands/UpdateCommand.java create mode 100644 src/main/java/io/floci/cli/update/ReleaseChannel.java diff --git a/src/main/java/io/floci/cli/FlociCli.java b/src/main/java/io/floci/cli/FlociCli.java index 90a6b2d..fdad926 100644 --- a/src/main/java/io/floci/cli/FlociCli.java +++ b/src/main/java/io/floci/cli/FlociCli.java @@ -41,7 +41,8 @@ AwsCommand.class, AzCommand.class, GcpCommand.class, - HelpCommand.class + HelpCommand.class, + UpdateCommand.class, } ) public class FlociCli implements Runnable { diff --git a/src/main/java/io/floci/cli/commands/UpdateCommand.java b/src/main/java/io/floci/cli/commands/UpdateCommand.java new file mode 100644 index 0000000..f36b76d --- /dev/null +++ b/src/main/java/io/floci/cli/commands/UpdateCommand.java @@ -0,0 +1,287 @@ +package io.floci.cli.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.floci.cli.output.Ansi; +import io.floci.cli.output.OutputFormat; +import io.floci.cli.output.Printer; +import io.floci.cli.update.ReleaseChannel; +import picocli.CommandLine.*; + +import java.io.IOException; +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.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.HexFormat; +import java.util.concurrent.Callable; + +/** + * Self-update: downloads the target release binary from the public GitHub repository + * (same source as installer/install.sh), verifies its sha256 against the release's + * {@code sha256sums.txt}, and atomically replaces the running binary. + *

+ * The replacement uses the classic self-update trick: a running executable cannot be + * written over, but its path can be atomically renamed onto — the running + * process keeps executing the old inode, and the next invocation picks up the new + * binary. The new file is staged in the same directory (same filesystem) so + * the final {@code ATOMIC_MOVE} can never leave a half-written binary. + *

+ */ +@Command( + name = "update", + description = "Update floci to the latest release" +) +public class UpdateCommand implements Callable { + + @Option(names = "--check", description = "Only report whether an update is available") + boolean check; + + // No mixinStandardHelpOptions here: its -V/--version would collide with this option. + @Option(names = "--version", paramLabel = "", description = "Target version (default: latest release)") + String to; + + @Option(names = {"-h", "--help"}, usageHelp = true, description = "Show this help message and exit") + boolean help; + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final HttpClient http = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + /** The binary to replace; {@code null} means "resolve the running executable". Test seam. */ + Path selfPath; + + @Override + public Integer call() { + if (System.console() == null) { + Ansi.disable(); + } + Printer printer = new Printer(System.out, System.err, OutputFormat.text, false); + try { + String current = VersionCommand.CLI_VERSION; + boolean pinned = to != null && !to.isBlank(); + String target = pinned ? to.trim() : fetchLatestVersion(); + + if (target.equals(current)) { + printer.println(Ansi.green("✓") + " floci " + current + " is up to date"); + return 0; + } + + printer.println("update available: " + current + " → " + Ansi.bold(target)); + if (check) { + printer.println(Ansi.gray("run 'floci update' to install it")); + return 0; + } + + Path binary = resolveSelf(); + requireNotBrewManaged(binary); + requireWritable(binary); + + String asset = "floci-" + detectPlatform(); + + Path work = Files.createTempDirectory("floci-update"); + try { + printer.println("downloading " + asset + " " + target + "..."); + Path fresh = work.resolve(asset); + fetchFile(ReleaseChannel.assetUrl(target, asset), fresh); + verifyChecksum(fresh, asset, fetchString(ReleaseChannel.assetUrl(target, "sha256sums.txt"))); + printer.println(Ansi.green("✓") + " checksum verified"); + replaceAtomically(fresh, binary); + } finally { + deleteRecursively(work); + } + + printer.println(Ansi.green("✓") + " updated: " + current + " → " + target + + Ansi.gray(" (" + binary + ")")); + return 0; + } catch (UpdateException e) { + printer.error(e.getMessage()); + return 1; + } catch (Exception e) { + printer.error("update failed: " + e.getMessage()); + return 1; + } + } + + // ── version resolution ─────────────────────────────────────────────────── + + 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"); + } + return tag; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new UpdateException("update check interrupted"); + } catch (Exception e) { + String cause = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + throw new UpdateException("cannot determine the latest version (" + cause + ").\n" + + "GitHub may be unreachable (offline?), or rate-limiting this address —\n" + + "pin a version with: floci update --version "); + } + } + + // ── self-location ──────────────────────────────────────────────────────── + + private Path resolveSelf() { + if (selfPath != null) { + return selfPath; + } + String cmd = ProcessHandle.current().info().command() + .orElseThrow(() -> new UpdateException("cannot locate the running executable")); + Path path = Path.of(cmd); + String file = path.getFileName().toString(); + if (file.equals("java") || file.equals("java.exe")) { + throw new UpdateException("running from a jar (java -jar), not the native binary — " + + "nothing to self-update. Reinstall instead: curl -fsSL https://floci.io/install.sh | sh"); + } + return path; + } + + /** + * A Homebrew install must be updated through brew: overwriting the Cellar binary + * would succeed silently (it's user-writable) but desync brew's bookkeeping, and + * the next {@code brew upgrade} would clobber it. + */ + private static void requireNotBrewManaged(Path binary) { + Path real; + try { + real = binary.toRealPath(); + } catch (IOException e) { + real = binary.toAbsolutePath(); + } + for (Path segment : real) { + String name = segment.toString(); + if (name.equals("Cellar") || name.equals("homebrew") || name.equals(".linuxbrew")) { + throw new UpdateException("this floci was installed with Homebrew — update it with:\n" + + " brew upgrade floci"); + } + } + } + + private static void requireWritable(Path binary) { + Path dir = binary.getParent(); + boolean ok = dir != null && Files.isWritable(dir) + && (!Files.exists(binary) || Files.isWritable(binary)); + if (!ok) { + throw new UpdateException("no write permission for " + binary + " — run: sudo floci update"); + } + } + + // ── platform (mirrors installer/install.sh) ────────────────────────────── + + static String detectPlatform() { + String os = System.getProperty("os.name", "").toLowerCase(); + String arch = System.getProperty("os.arch", "").toLowerCase(); + String o = null; + if (os.contains("mac")) { + o = "darwin"; + } else if (os.contains("linux")) { + o = "linux"; + } else if (os.contains("windows")) { + throw new UpdateException("self-update is not supported on Windows — " + + "re-run the installer: irm https://floci.io/install.ps1 | iex"); + } + String a = switch (arch) { + case "aarch64", "arm64" -> "arm64"; + case "x86_64", "amd64" -> "amd64"; + default -> null; + }; + if (o == null || a == null) { + throw new UpdateException("no published build for this platform (" + os + "/" + arch + ")"); + } + return o + "-" + a; + } + + // ── download + verify ──────────────────────────────────────────────────── + + private String fetchString(String url) throws IOException, InterruptedException { + HttpResponse resp = http.send(get(url), HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200 || resp.body().isBlank()) { + throw new IOException("GET " + url + " → HTTP " + resp.statusCode()); + } + return resp.body(); + } + + private void fetchFile(String url, Path dest) throws IOException, InterruptedException { + HttpResponse resp = http.send(get(url), HttpResponse.BodyHandlers.ofFile(dest)); + if (resp.statusCode() != 200) { + throw new UpdateException("download failed: " + url + " → HTTP " + resp.statusCode() + + (resp.statusCode() == 404 ? " (does that release exist?)" : "")); + } + if (Files.size(dest) == 0) { + // GitHub's CDN can intermittently hand back a 0-byte body (see install.sh). + throw new UpdateException("download failed: " + url + " returned an empty body — please retry"); + } + } + + private HttpRequest get(String url) { + return HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofMinutes(2)) + .GET().build(); + } + + private static void verifyChecksum(Path file, String asset, String checksums) + throws IOException, java.security.NoSuchAlgorithmException { + // Entries are CI artifact paths like "./floci-darwin-arm64/floci-darwin-arm64", + // so match on the basename (install.sh does the same with an end-of-line grep). + String expected = checksums.lines() + .map(l -> l.trim().split("\\s+")) + .filter(p -> p.length == 2 && p[1].substring(p[1].lastIndexOf('/') + 1).equals(asset)) + .map(p -> p[0]) + .findFirst() + .orElseThrow(() -> new UpdateException(asset + " not found in sha256sums.txt — aborting")); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file)); + String actual = HexFormat.of().formatHex(digest); + if (!actual.equals(expected)) { + throw new UpdateException("checksum mismatch for " + asset + "\n expected: " + expected + + "\n actual: " + actual + "\nThe download may be corrupt or tampered with. Aborting."); + } + } + + // ── atomic replace ─────────────────────────────────────────────────────── + + private static void replaceAtomically(Path fresh, Path target) throws IOException { + // Stage in the TARGET's directory (same filesystem), then atomically rename over it. + // The running process keeps its old inode; the path now serves the new binary. + Path staged = target.resolveSibling("." + target.getFileName() + ".new"); + Files.copy(fresh, staged, StandardCopyOption.REPLACE_EXISTING); + if (staged.getFileSystem().supportedFileAttributeViews().contains("posix")) { + Files.setPosixFilePermissions(staged, PosixFilePermissions.fromString("rwxr-xr-x")); + } + Files.move(staged, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + + private static void deleteRecursively(Path root) { + try (var walk = Files.walk(root)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException _) { + // best-effort temp cleanup — a leftover temp dir is harmless + } + }); + } catch (IOException _) { + // best-effort temp cleanup — a leftover temp dir is harmless + } + } + + /** Expected failure with a user-facing message (no stack trace). */ + private static final class UpdateException extends RuntimeException { + UpdateException(String message) { + super(message); + } + } +} diff --git a/src/main/java/io/floci/cli/update/ReleaseChannel.java b/src/main/java/io/floci/cli/update/ReleaseChannel.java new file mode 100644 index 0000000..950fecb --- /dev/null +++ b/src/main/java/io/floci/cli/update/ReleaseChannel.java @@ -0,0 +1,32 @@ +package io.floci.cli.update; + +/** + * Where releases live: the public GitHub repository, same source as installer/install.sh. + * Both URLs are env-overridable so tests can point at a local server and mirrors work + * in air-gapped setups. + */ +public final class ReleaseChannel { + + public static final String REPO = "floci-io/floci-cli"; + + /** Overrides the GitHub API base URL (used to resolve the latest release tag). */ + public static final String API_ENV = "FLOCI_UPDATE_API_URL"; + /** Overrides the release-asset download base URL. */ + public static final String DOWNLOAD_ENV = "FLOCI_UPDATE_DOWNLOAD_URL"; + + private ReleaseChannel() { + } + + /** GET this to resolve the latest release; the JSON's {@code tag_name} is the version. */ + public static String latestReleaseUrl() { + String base = System.getenv().getOrDefault(API_ENV, "https://api.github.com"); + return base + "/repos/" + REPO + "/releases/latest"; + } + + /** Download URL for one asset of one release, e.g. {@code floci-darwin-arm64}. */ + public static String assetUrl(String version, String asset) { + String base = System.getenv().getOrDefault( + DOWNLOAD_ENV, "https://github.com/" + REPO + "/releases/download"); + return base + "/" + version + "/" + asset; + } +} From 67d3415cee1442b5fc04299deb8753610e32bc59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 3 Jul 2026 15:31:33 -0400 Subject: [PATCH 3/7] fix(update): harden self-update checksum, cleanup, and exit code --- .../io/floci/cli/commands/UpdateCommand.java | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/main/java/io/floci/cli/commands/UpdateCommand.java b/src/main/java/io/floci/cli/commands/UpdateCommand.java index f36b76d..65c5262 100644 --- a/src/main/java/io/floci/cli/commands/UpdateCommand.java +++ b/src/main/java/io/floci/cli/commands/UpdateCommand.java @@ -8,7 +8,9 @@ import io.floci.cli.update.ReleaseChannel; import picocli.CommandLine.*; +import java.io.BufferedInputStream; import java.io.IOException; +import java.io.OutputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -17,6 +19,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermissions; +import java.security.DigestInputStream; import java.security.MessageDigest; import java.time.Duration; import java.util.HexFormat; @@ -40,7 +43,8 @@ ) public class UpdateCommand implements Callable { - @Option(names = "--check", description = "Only report whether an update is available") + @Option(names = "--check", + description = "Only report whether an update is available (exit 0: up to date, 1: update available)") boolean check; // No mixinStandardHelpOptions here: its -V/--version would collide with this option. @@ -79,7 +83,8 @@ public Integer call() { printer.println("update available: " + current + " → " + Ansi.bold(target)); if (check) { printer.println(Ansi.gray("run 'floci update' to install it")); - return 0; + // Non-zero so scripts can gate on staleness: floci update --check || floci update + return 1; } Path binary = resolveSelf(); @@ -172,11 +177,11 @@ private static void requireNotBrewManaged(Path binary) { } private static void requireWritable(Path binary) { + // The atomic rename replaces a directory entry — it only needs write access to the + // parent directory, never to the binary itself (which installers often leave 555). Path dir = binary.getParent(); - boolean ok = dir != null && Files.isWritable(dir) - && (!Files.exists(binary) || Files.isWritable(binary)); - if (!ok) { - throw new UpdateException("no write permission for " + binary + " — run: sudo floci update"); + if (dir == null || !Files.isWritable(dir)) { + throw new UpdateException("no write permission for " + dir + " — run: sudo floci update"); } } @@ -243,8 +248,13 @@ private static void verifyChecksum(Path file, String asset, String checksums) .map(p -> p[0]) .findFirst() .orElseThrow(() -> new UpdateException(asset + " not found in sha256sums.txt — aborting")); - byte[] digest = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file)); - String actual = HexFormat.of().formatHex(digest); + // Stream the digest — the binary is tens of MB and must not be held in heap whole. + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + try (var in = new DigestInputStream( + new BufferedInputStream(Files.newInputStream(file)), sha256)) { + in.transferTo(OutputStream.nullOutputStream()); + } + String actual = HexFormat.of().formatHex(sha256.digest()); if (!actual.equals(expected)) { throw new UpdateException("checksum mismatch for " + asset + "\n expected: " + expected + "\n actual: " + actual + "\nThe download may be corrupt or tampered with. Aborting."); @@ -257,11 +267,22 @@ private static void replaceAtomically(Path fresh, Path target) throws IOExceptio // Stage in the TARGET's directory (same filesystem), then atomically rename over it. // The running process keeps its old inode; the path now serves the new binary. Path staged = target.resolveSibling("." + target.getFileName() + ".new"); - Files.copy(fresh, staged, StandardCopyOption.REPLACE_EXISTING); - if (staged.getFileSystem().supportedFileAttributeViews().contains("posix")) { - Files.setPosixFilePermissions(staged, PosixFilePermissions.fromString("rwxr-xr-x")); + try { + Files.copy(fresh, staged, StandardCopyOption.REPLACE_EXISTING); + if (staged.getFileSystem().supportedFileAttributeViews().contains("posix")) { + Files.setPosixFilePermissions(staged, PosixFilePermissions.fromString("rwxr-xr-x")); + } + Files.move(staged, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + // Don't leave ..new debris next to the production binary — the temp-dir + // cleanup in call() never sees this file. + try { + Files.deleteIfExists(staged); + } catch (IOException _) { + // best effort; the original failure is what matters + } + throw e; } - Files.move(staged, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } private static void deleteRecursively(Path root) { From 856218526a57b1d45f9f24699ea5b4e1f50e8b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 3 Jul 2026 15:58:24 -0400 Subject: [PATCH 4/7] feat(update): notify when a new release is available --- README.md | 19 ++ src/main/java/io/floci/cli/FlociCli.java | 31 +++- .../java/io/floci/cli/update/UpdateCache.java | 5 + .../io/floci/cli/update/UpdateNotifier.java | 165 ++++++++++++++++++ .../java/io/floci/cli/update/Version.java | 44 +++++ 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/floci/cli/update/UpdateCache.java create mode 100644 src/main/java/io/floci/cli/update/UpdateNotifier.java create mode 100644 src/main/java/io/floci/cli/update/Version.java diff --git a/README.md b/README.md index e9d7945..ab6bb39 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Commands are organized into two product groups — `floci aws` (or bare `floci`) | `floci config profile` | Manage named profiles | | `floci config default-product` | Set the default product (aws or az) | | `floci completion bash\|zsh` | Generate shell completion scripts | +| `floci update` | Update the CLI to the latest release | ### AWS commands (`floci` / `floci aws`) @@ -393,6 +394,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..6ec47ea 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); } @@ -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; 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: + *
    + *
  • {@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.
  • + *
  • {@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.
  • + *
+ */ +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("+"); + } +} From 5c1b8cea1e63c39beada80b63a7277fbdb7572e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 3 Jul 2026 16:27:59 -0400 Subject: [PATCH 5/7] fix(update): validate release tag, avoid pre-release downgrades, clarify empty-body error --- .../io/floci/cli/commands/UpdateCommand.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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(); } From f7ee989d4e724d50103cc0ceefc1c01c8db1519a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 3 Jul 2026 15:58:24 -0400 Subject: [PATCH 6/7] feat(update): notify when a new release is available --- README.md | 19 ++ src/main/java/io/floci/cli/FlociCli.java | 31 +++- .../java/io/floci/cli/update/UpdateCache.java | 5 + .../io/floci/cli/update/UpdateNotifier.java | 165 ++++++++++++++++++ .../java/io/floci/cli/update/Version.java | 44 +++++ 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/floci/cli/update/UpdateCache.java create mode 100644 src/main/java/io/floci/cli/update/UpdateNotifier.java create mode 100644 src/main/java/io/floci/cli/update/Version.java 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..6ec47ea 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); } @@ -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; 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: + *
    + *
  • {@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.
  • + *
  • {@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.
  • + *
+ */ +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("+"); + } +} From 3b492c1bc0ee3550082d77f3afbfc9c44b6c3f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Thu, 9 Jul 2026 19:17:52 -0400 Subject: [PATCH 7/7] fix(cli): exempt update from default-product routing --- src/main/java/io/floci/cli/FlociCli.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/floci/cli/FlociCli.java b/src/main/java/io/floci/cli/FlociCli.java index 6ec47ea..47364d8 100644 --- a/src/main/java/io/floci/cli/FlociCli.java +++ b/src/main/java/io/floci/cli/FlociCli.java @@ -116,7 +116,8 @@ 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; }