-
-
Notifications
You must be signed in to change notification settings - Fork 16
Feature/notifier-update #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
b1b0f39
fix: align ContainerInfo fields with inspect format template
juandiii b35794c
Merge branch 'floci-io:main' into main
juandiii 657b316
feat(update): add self-update command with checksum verification
juandiii 67d3415
fix(update): harden self-update checksum, cleanup, and exit code
juandiii 8562185
feat(update): notify when a new release is available
juandiii 5c1b8ce
fix(update): validate release tag, avoid pre-release downgrades, clar…
juandiii d215dca
Merge branch 'floci-io:main' into main
juandiii f7ee989
feat(update): notify when a new release is available
juandiii ff882ce
Merge branch 'main' into feature/notifier-update
juandiii 3b492c1
fix(cli): exempt update from default-product routing
juandiii File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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("+"); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.