This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-26748-self-update-nightly
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 6e4ccb2344fbf5f0d31993f51f2a127cb8ea2425
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Nov 8 23:24:41 2026 +0100

    CAMEL-26748: camel-jbang - self-update and install.sh: switch to a nightly 
SNAPSHOT build of the Camel CLI
    
    camel self-update --version SNAPSHOT (main) or X.Y.Z-SNAPSHOT installs the
    newest nightly build of the launcher from the Apache snapshot repository, 
and
    pins it, so a later camel self-update follows the nightly builds. An 
explicit
    release --version goes back to the releases. install.sh takes the same 
values.
    
    - each nightly build is installed in its own directory, named after it
      (4.24.0-20261108.143653-24), so a running command keeps its files; the 
newest
      three are kept, releases are never removed
    - the build is resolved from maven-metadata.xml (Maven 3 and the Maven 2 
form
      Nexus 2 serves to the JDK HTTP client), and checked against a strict 
pattern
      before it is used in a directory name or URL; DTDs are disabled
    - verified against the SHA-1 next to it in the snapshot repository, with a 
note
      that it is a development build, not an Apache release
    - camel version shows the nightly build and when it was built
    - no update notice for a pin to nightly builds
    
    install.ps1 does not install nightly builds yet.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../ROOT/pages/camel-jbang-launcher-install.adoc   |  46 ++++
 .../jbang/core/commands/version/VersionGet.java    |  11 +
 .../dsl/jbang/core/common/InstallDetector.java     |  61 ++++++
 .../core/commands/version/VersionGetTest.java      |  24 ++
 .../dsl/jbang/core/common/InstallDetectorTest.java |  26 +++
 .../camel-launcher/src/install/install.sh          | 185 +++++++++++++++-
 .../jbang/launcher/selfupdate/ManifestFetcher.java | 145 +++++++++++-
 .../dsl/jbang/launcher/selfupdate/Nightly.java     | 121 +++++++++++
 .../camel/dsl/jbang/launcher/selfupdate/Pin.java   |  34 ++-
 .../launcher/selfupdate/SelfUpdateCommand.java     |  97 ++++++++-
 .../jbang/launcher/selfupdate/UpdateChecker.java   |   3 +-
 .../jbang/launcher/SelfUpdateIntegrationTest.java  | 117 +++++++++-
 .../dsl/jbang/launcher/WebsiteInstallTest.java     | 242 ++++++++++++++++++++-
 .../jbang/launcher/WebsiteInstallerFixture.java    |   5 +
 .../launcher/selfupdate/ManifestFetcherTest.java   | 153 +++++++++++++
 .../dsl/jbang/launcher/selfupdate/NightlyTest.java |  77 +++++++
 .../dsl/jbang/launcher/selfupdate/PinTest.java     |  45 +++-
 17 files changed, 1360 insertions(+), 32 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc
index fdffff12e6f5..1b236621d50b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-launcher-install.adoc
@@ -127,6 +127,52 @@ one (for example after a `--version LTS` install that 
resolved to 4.22.2), `came
 with a "pinned to version LTS" message. Re-run the installer with `--version 
LTS` (or `--version 4.22`)
 to update instead.
 
+== Nightly development builds
+
+Apache publishes a nightly build of the Camel CLI to the Apache snapshot 
repository, for main and for the
+maintenance branches. It lets you try the latest development code, or a fix 
before its patch release, without
+building Camel yourself.
+
+IMPORTANT: A nightly build is a development build for testing, not an Apache 
release. Do not use it in production.
+
+[source,bash]
+----
+camel self-update --version SNAPSHOT          # the newest nightly build of 
main
+camel self-update --version 4.22.3-SNAPSHOT   # the newest nightly build of a 
maintenance branch
+camel self-update --version LTS               # back to a release (or 
--version X.Y.Z)
+----
+
+The installer takes the same values:
+
+[source,bash]
+----
+curl -fsSL https://camel.apache.org/install.sh | sh -s -- --version SNAPSHOT
+----
+
+`SNAPSHOT` and `X.Y.Z-SNAPSHOT` are recorded as the pin, so a later `camel 
self-update` installs the newest nightly
+build of main, or of that version. Installing a release with `--version` (or 
running the installer without
+`--version`) leaves the nightly builds and goes back to the releases. A pin to 
nightly builds gets no update notice, as
+a new build lands every day.
+
+Each nightly build is installed in its own directory, named after it, such as
+`versions/4.24.0-20261108.143653-24` (the version, the UTC time it was built, 
and the build number). A command that
+still runs from the previous build keeps its files. The installer keeps the 
three newest nightly builds and removes the
+older ones; it never removes a release. `camel version` shows which nightly 
build runs, and when it was built:
+
+[source]
+----
+$ camel version
+Camel CLI version: 4.24.0-SNAPSHOT
+Nightly build: 4.24.0-20261108.143653-24 (built 2026-11-08 14:36:53 UTC)
+----
+
+A release is verified against a SHA-256 recorded in a manifest on the Camel 
website, a different server than the one
+it is downloaded from. A nightly build is verified only against the SHA-1 
published next to it in the snapshot
+repository: this catches a broken download, not a tampered one.
+
+NOTE: Installing nightly builds needs a Camel CLI and installer that support 
them. The Windows installer
+(`install.ps1`) does not install nightly builds yet.
+
 == Requirements
 
 * A Java 21+ runtime available at install and run time. The launcher locates 
Java from
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGet.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGet.java
index 386992207be5..6e75e8a68020 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGet.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGet.java
@@ -16,11 +16,14 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.version;
 
+import java.time.format.DateTimeFormatter;
+
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
 import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.InstallDetector;
 import org.apache.camel.dsl.jbang.run.common.VersionHelper;
 import picocli.CommandLine;
 
@@ -49,6 +52,14 @@ public class VersionGet extends CamelCommand {
         CamelCatalog catalog = new DefaultCamelCatalog();
         String v = catalog.getCatalogVersion();
         printer().println("Camel CLI version: " + v);
+        // a nightly build has a SNAPSHOT version: the build tells which one, 
and when it was built
+        InstallDetector.runningNightlyBuild().ifPresent(build -> 
printer().println("Nightly build: " + build
+                                                                               
    + InstallDetector.nightlyBuildTime(build)
+                                                                               
            .map(t -> " (built " + t.format(
+                                                                               
                    DateTimeFormatter.ofPattern(
+                                                                               
                            "yyyy-MM-dd HH:mm:ss"))
+                                                                               
                      + " UTC)")
+                                                                               
            .orElse("")));
 
         CommandLineHelper.loadProperties(properties -> {
             String uv = properties.getProperty("camel-version");
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/InstallDetector.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/InstallDetector.java
index be4b6e5159ce..f37863a39e4d 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/InstallDetector.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/InstallDetector.java
@@ -22,11 +22,18 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
 import java.util.function.Predicate;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * Detects where the currently running Camel CLI launcher lives, and discovers 
other Camel CLI installations on the same
@@ -55,9 +62,63 @@ public final class InstallDetector {
     private static final boolean IS_WINDOWS
             = System.getProperty("os.name", 
"").toLowerCase(Locale.ROOT).startsWith("windows");
 
+    // A nightly build as the Apache snapshot repository names it, which the 
web installer uses as its directory name:
+    // X.Y.Z-YYYYMMDD.HHMMSS-N, the UTC timestamp and build number of the 
deploy.
+    private static final Pattern NIGHTLY_BUILD
+            = 
Pattern.compile("[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]{8}\\.[0-9]{6})-[0-9]{1,9}");
+    private static final DateTimeFormatter NIGHTLY_TIMESTAMP = 
DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss");
+
     private InstallDetector() {
     }
 
+    /**
+     * The nightly build the running launcher was installed as by the web 
installer ({@code camel self-update --version
+     * SNAPSHOT}), such as {@code 4.24.0-20261108.143653-24}: the name of its 
directory under
+     * {@link #webInstallerVersionsRoot()}, when that is a nightly build.
+     */
+    public static Optional<String> runningNightlyBuild() {
+        String jarPath = System.getProperty("camel.launcher.jar");
+        if (jarPath == null || jarPath.isBlank()) {
+            return Optional.empty();
+        }
+        return nightlyBuildOf(Paths.get(jarPath), webInstallerVersionsRoot());
+    }
+
+    /**
+     * The nightly build a launcher JAR belongs to: the name of its directory 
under the web installer's
+     * {@code versionsRoot}, when that is a nightly build ({@code 
X.Y.Z-YYYYMMDD.HHMMSS-N}).
+     */
+    public static Optional<String> nightlyBuildOf(Path launcherJar, Path 
versionsRoot) {
+        if (launcherJar == null) {
+            return Optional.empty();
+        }
+        Path jar = launcherJar.toAbsolutePath().normalize();
+        Path root = versionsRoot.toAbsolutePath().normalize();
+        if (!jar.startsWith(root) || jar.getNameCount() <= 
root.getNameCount()) {
+            return Optional.empty();
+        }
+        String dir = jar.getName(root.getNameCount()).toString();
+        return isNightlyBuild(dir) ? Optional.of(dir) : Optional.empty();
+    }
+
+    /** Whether {@code value} is a nightly build, {@code 
X.Y.Z-YYYYMMDD.HHMMSS-N}. */
+    public static boolean isNightlyBuild(String value) {
+        return value != null && NIGHTLY_BUILD.matcher(value).matches();
+    }
+
+    /** When a nightly build was deployed to the snapshot repository (its 
timestamp is UTC). */
+    public static Optional<ZonedDateTime> nightlyBuildTime(String build) {
+        Matcher m = build != null ? NIGHTLY_BUILD.matcher(build) : null;
+        if (m == null || !m.matches()) {
+            return Optional.empty();
+        }
+        try {
+            return Optional.of(LocalDateTime.parse(m.group(1), 
NIGHTLY_TIMESTAMP).atZone(ZoneOffset.UTC));
+        } catch (DateTimeParseException e) {
+            return Optional.empty();
+        }
+    }
+
     /**
      * Determines where the currently running launcher lives, by resolving the 
path of the JAR backing this JVM process
      * (set as the {@code camel.launcher.jar} system property by {@code 
CamelLauncher.main()}).
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGetTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGetTest.java
index 7e32eb22b145..3a0f73f413c3 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGetTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/version/VersionGetTest.java
@@ -26,6 +26,7 @@ import 
org.apache.camel.dsl.jbang.core.commands.CamelCommandBaseTestSupport;
 import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
 import org.apache.camel.dsl.jbang.core.commands.UserConfigHelper;
 import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.dsl.jbang.core.common.InstallDetector;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -88,6 +89,29 @@ class VersionGetTest extends CamelCommandBaseTestSupport {
         Assertions.assertEquals("kamelets-version = 4.18.0", lines.get(3));
     }
 
+    @Test
+    public void shouldPrintTheNightlyBuildAndWhenItWasBuilt() throws Exception 
{
+        UserConfigHelper.createUserConfig("");
+        Path versions = InstallDetector.webInstallerVersionsRoot();
+        String saved = System.getProperty("camel.launcher.jar");
+        System.setProperty("camel.launcher.jar",
+                
versions.resolve("4.24.0-20261108.143653-24/lib/camel-launcher-4.24.0-SNAPSHOT.jar").toString());
+        try {
+            VersionGet command = createCommand();
+            command.doCall();
+        } finally {
+            if (saved != null) {
+                System.setProperty("camel.launcher.jar", saved);
+            } else {
+                System.clearProperty("camel.launcher.jar");
+            }
+        }
+
+        List<String> lines = printer.getLines();
+        Assertions.assertTrue(lines.get(0).startsWith("Camel CLI version:"));
+        Assertions.assertEquals("Nightly build: 4.24.0-20261108.143653-24 
(built 2026-11-08 14:36:53 UTC)", lines.get(1));
+    }
+
     private void createJBangVersionFile(String version) throws IOException {
         Path jBangDir = CommandLineHelper.getHomeDir().resolve(".jbang/cache");
         if (!jBangDir.toFile().exists()) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/InstallDetectorTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/InstallDetectorTest.java
index 8e14e3e2cd01..d6f9ed743bf3 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/InstallDetectorTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/InstallDetectorTest.java
@@ -19,6 +19,8 @@ package org.apache.camel.dsl.jbang.core.common;
 import java.io.File;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
 
 import org.apache.camel.util.FileUtil;
 import org.junit.jupiter.api.Test;
@@ -183,6 +185,30 @@ class InstallDetectorTest {
         assertThat(InstallDetector.followLinks(a)).isIn(a, b);
     }
 
+    @Test
+    void nightlyBuildIsTheVersionDirectoryOfTheLauncher() {
+        Path versions = Path.of("/home/user/.local/share/camel-cli/versions");
+
+        assertThat(InstallDetector.nightlyBuildOf(
+                
versions.resolve("4.24.0-20261108.143653-24/lib/camel-launcher-4.24.0-SNAPSHOT.jar"),
 versions))
+                .contains("4.24.0-20261108.143653-24");
+        
assertThat(InstallDetector.nightlyBuildOf(versions.resolve("4.23.0/lib/camel-launcher-4.23.0.jar"),
 versions))
+                .isEmpty();
+        
assertThat(InstallDetector.nightlyBuildOf(Path.of("/opt/camel/lib/camel-launcher.jar"),
 versions)).isEmpty();
+        assertThat(InstallDetector.nightlyBuildOf(versions, 
versions)).isEmpty();
+        assertThat(InstallDetector.nightlyBuildOf(null, versions)).isEmpty();
+    }
+
+    @Test
+    void nightlyBuildTimeIsTheUtcDeployTimestamp() {
+        
assertThat(InstallDetector.nightlyBuildTime("4.24.0-20261108.143653-24"))
+                .contains(ZonedDateTime.of(2026, 11, 8, 14, 36, 53, 0, 
ZoneOffset.UTC));
+        // not a valid date or time
+        
assertThat(InstallDetector.nightlyBuildTime("4.24.0-20261308.143653-24")).isEmpty();
+        assertThat(InstallDetector.nightlyBuildTime("4.24.0")).isEmpty();
+        assertThat(InstallDetector.nightlyBuildTime(null)).isEmpty();
+    }
+
     @Test
     void resolveActiveOnPathReturnsEmptyWhenNotFound() {
         var result = InstallDetector.resolveActiveOnPath("/nonexistent/dir-a" 
+ File.pathSeparator + "/nonexistent/dir-b");
diff --git a/dsl/camel-jbang/camel-launcher/src/install/install.sh 
b/dsl/camel-jbang/camel-launcher/src/install/install.sh
index 9b6f401e9076..d2b97d5607f9 100755
--- a/dsl/camel-jbang/camel-launcher/src/install/install.sh
+++ b/dsl/camel-jbang/camel-launcher/src/install/install.sh
@@ -21,6 +21,7 @@ set -eu
 # Test seams only: production installs never set these, so the defaults below 
are always used.
 
manifest_base_url="${CAMEL_INSTALL_MANIFEST_BASE_URL:-https://camel.apache.org/camel-cli/releases}";
 
maven_base_url="${CAMEL_INSTALL_MAVEN_BASE_URL:-https://repo1.maven.org/maven2/org/apache/camel/camel-launcher}";
+snapshot_base_url="${CAMEL_INSTALL_SNAPSHOT_BASE_URL:-https://repository.apache.org/content/repositories/snapshots/org/apache/camel/camel-launcher}";
 ca_cert="${CAMEL_INSTALL_CA_CERT:-}"
 
 data_root="${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli/versions"
@@ -59,8 +60,38 @@ is_valid_line() {
     esac
 }
 
-# Classifies a --version value as an exact release (X.Y.Z), a release line 
(X.Y) or the newest LTS release
-# (LTS, case-insensitive). Sets $selector_kind and normalises 
$requested_version ('lts' becomes 'LTS').
+# A snapshot version: X.Y.Z-SNAPSHOT.
+is_valid_snapshot() {
+    case "$1" in
+        *-SNAPSHOT) is_valid_version "${1%-SNAPSHOT}" ;;
+        *) return 1 ;;
+    esac
+}
+
+# One nightly build, as the snapshot repository names it: 
X.Y.Z-YYYYMMDD.HHMMSS-N. The value ends up in a directory
+# name and a download URL, so it is checked character by character.
+is_valid_nightly() {
+    nb_value="$1"
+    nb_base="${nb_value%%-*}"
+    nb_rest="${nb_value#*-}"
+    [ "$nb_rest" != "$nb_value" ] || return 1
+    is_valid_version "$nb_base" || return 1
+    nb_number="${nb_rest##*-}"
+    nb_stamp="${nb_rest%-*}"
+    case "$nb_stamp" in
+        
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].[0-9][0-9][0-9][0-9][0-9][0-9]) ;;
+        *) return 1 ;;
+    esac
+    case "$nb_number" in
+        '' | *[!0-9]*) return 1 ;;
+    esac
+    [ ${#nb_number} -le 9 ]
+}
+
+# Classifies a --version value as an exact release (X.Y.Z), a release line 
(X.Y), the newest LTS release (LTS,
+# case-insensitive), the nightly builds of main (SNAPSHOT, case-insensitive) 
or of a snapshot version
+# (X.Y.Z-SNAPSHOT), or one nightly build (X.Y.Z-YYYYMMDD.HHMMSS-N). Sets 
$selector_kind and normalises
+# $requested_version ('lts' becomes 'LTS', 'snapshot' becomes 'SNAPSHOT').
 is_valid_selector() {
     case "$requested_version" in
         [Ll][Tt][Ss])
@@ -68,7 +99,20 @@ is_valid_selector() {
             selector_kind="lts"
             return 0
             ;;
+        [Ss][Nn][Aa][Pp][Ss][Hh][Oo][Tt])
+            requested_version="SNAPSHOT"
+            selector_kind="snapshot"
+            return 0
+            ;;
     esac
+    if is_valid_snapshot "$requested_version"; then
+        selector_kind="snapshot"
+        return 0
+    fi
+    if is_valid_nightly "$requested_version"; then
+        selector_kind="nightly"
+        return 0
+    fi
     if is_valid_version "$requested_version"; then
         selector_kind="exact"
         return 0
@@ -109,6 +153,19 @@ fetch() {
     fi
 }
 
+sha1() {
+    file="$1"
+    if command -v sha1sum >/dev/null 2>&1; then
+        sha1sum "$file" | awk '{print $1}'
+    elif command -v shasum >/dev/null 2>&1; then
+        shasum -a 1 "$file" | awk '{print $1}'
+    elif command -v openssl >/dev/null 2>&1; then
+        openssl dgst -sha1 "$file" | awk '{print $NF}'
+    else
+        fail "sha1sum, shasum, or openssl is required to verify the download"
+    fi
+}
+
 sha256() {
     file="$1"
     if command -v sha256sum >/dev/null 2>&1; then
@@ -252,6 +309,112 @@ activate() {
     mv -f "$tmp_link" "$bin_dir/camel"
 }
 
+# The snapshot version of main: the highest X.Y.Z-SNAPSHOT listed in the 
snapshot repository metadata (the
+# maintenance branches publish nightly builds too, under lower versions). 
Maven writes one element per line.
+latest_snapshot_version() {
+    sed -n 's|.*<version>\([^<]*\)</version>.*|\1|p' "$1" | while IFS= read -r 
lsv; do
+        is_valid_snapshot "$lsv" && printf '%s\n' "${lsv%-SNAPSHOT}"
+    done | sort -t. -n -k1,1 -k2,2 -k3,3 | tail -1
+}
+
+# The newest nightly build of a snapshot version ($2, X.Y.Z) from its metadata 
($1): the <value> of the bin tar.gz
+# <snapshotVersion> (Maven 3 metadata), or else the <timestamp>-<buildNumber> 
of the <snapshot> (Maven 2 metadata,
+# which Nexus 2 serves to a client it does not take for Maven 3), as all the 
files of one deploy share them.
+newest_nightly() {
+    awk -v base="$2" '
+        /<snapshotVersion>/ { inside = 1; c = ""; e = ""; v = "" }
+        inside && /<classifier>/ { c = $0; 
gsub(/.*<classifier>|<\/classifier>.*/, "", c) }
+        inside && /<extension>/ { e = $0; 
gsub(/.*<extension>|<\/extension>.*/, "", e) }
+        inside && /<value>/ { v = $0; gsub(/.*<value>|<\/value>.*/, "", v) }
+        /<\/snapshotVersion>/ { if (c == "bin" && e == "tar.gz") { found = v } 
inside = 0 }
+        /<timestamp>/ && ts == "" { ts = $0; 
gsub(/.*<timestamp>|<\/timestamp>.*/, "", ts) }
+        /<buildNumber>/ && bn == "" { bn = $0; 
gsub(/.*<buildNumber>|<\/buildNumber>.*/, "", bn) }
+        END {
+            if (found != "") { print found } else if (ts != "" && bn != "") { 
print base "-" ts "-" bn }
+        }
+    ' "$1"
+}
+
+# Removes all but the newest three nightly builds, never the one just 
installed: a command that still runs from one
+# of them keeps its files. Releases are never removed.
+prune_nightlies() {
+    keep_build="$1"
+    ls "$data_root" | while IFS= read -r pn_dir; do
+        is_valid_nightly "$pn_dir" && printf '%s %s\n' "${pn_dir#*-}" "$pn_dir"
+    done | sort -r | awk 'NR > 3 { print $2 }' | while IFS= read -r pn_old; do
+        [ "$pn_old" = "$keep_build" ] || rm -rf "${data_root:?}/$pn_old"
+    done
+}
+
+# Installs a nightly build from the snapshot repository, verified against the 
SHA-1 published next to it there: a
+# nightly build is no Apache release, so there is no manifest on 
camel.apache.org for it.
+install_nightly() {
+    case "$requested_version" in
+        SNAPSHOT)
+            metadata_file="$staging_dir/metadata.xml"
+            fetch "$snapshot_base_url/maven-metadata.xml" "$metadata_file" \
+                || fail "failed to download the snapshot metadata from 
$snapshot_base_url/maven-metadata.xml"
+            snapshot_base="$(latest_snapshot_version "$metadata_file")"
+            [ -n "$snapshot_base" ] || fail "the snapshot repository lists no 
X.Y.Z-SNAPSHOT version"
+            snapshot_version="$snapshot_base-SNAPSHOT"
+            ;;
+        *-SNAPSHOT)
+            snapshot_version="$requested_version"
+            ;;
+        *)
+            snapshot_version="${requested_version%%-*}-SNAPSHOT"
+            nightly="$requested_version"
+            ;;
+    esac
+    if [ -z "${nightly:-}" ]; then
+        metadata_file="$staging_dir/$snapshot_version-metadata.xml"
+        fetch "$snapshot_base_url/$snapshot_version/maven-metadata.xml" 
"$metadata_file" \
+            || fail "no nightly builds of $snapshot_version at 
$snapshot_base_url/$snapshot_version/maven-metadata.xml"
+        nightly="$(newest_nightly "$metadata_file" 
"${snapshot_version%-SNAPSHOT}")"
+    fi
+    is_valid_nightly "$nightly" && [ "${nightly%%-*}-SNAPSHOT" = 
"$snapshot_version" ] \
+        || fail "the snapshot metadata names an invalid nightly build of 
$snapshot_version: $nightly"
+
+    # camel self-update printed the note already
+    if [ "${CAMEL_INSTALL_SELF_UPDATE:-}" != "true" ]; then
+        echo "Note: $nightly is a nightly development build of Apache Camel, 
for testing only. It is not an Apache" \
+             "release, and is verified only against the SHA-1 published next 
to it in the snapshot repository."
+    fi
+
+    
archive_url="$snapshot_base_url/$snapshot_version/camel-launcher-$nightly-bin.tar.gz"
+    archive_file="$staging_dir/camel-launcher-$nightly-bin.tar.gz"
+    fetch "$archive_url" "$archive_file" || fail "failed to download archive 
from $archive_url"
+    sha1_file="$staging_dir/archive.sha1"
+    fetch "$archive_url.sha1" "$sha1_file" || fail "failed to download 
checksum from $archive_url.sha1"
+    expected_sha1="$(awk '{ print $1; exit }' "$sha1_file")"
+    [ ${#expected_sha1} -eq 40 ] || fail "published SHA-1 is not a 
40-character lowercase hex value"
+    case "$expected_sha1" in
+        *[!0-9a-f]*) fail "published SHA-1 is not a 40-character lowercase hex 
value" ;;
+    esac
+    [ "$(sha1 "$archive_file")" = "$expected_sha1" ] || fail "checksum 
mismatch for downloaded archive"
+
+    # The archive of a nightly build unpacks to camel-launcher-X.Y.Z-SNAPSHOT.
+    validate_tar "$archive_file" "$snapshot_version"
+    extract_dir="$staging_dir/extract"
+    mkdir -p "$extract_dir"
+    tar -xzf "$archive_file" -C "$extract_dir"
+    staged_root_dir="$extract_dir/camel-launcher-$snapshot_version"
+    verify_staged "$staged_root_dir/bin/camel.sh"
+
+    activate "$nightly" "$staged_root_dir"
+    update_pin_state
+    prune_nightlies "$nightly"
+    print_path_hint
+    echo "Installed Camel CLI $nightly (a nightly build of $snapshot_version) 
to $data_root/$nightly"
+}
+
+print_path_hint() {
+    case ":$PATH:" in
+        *":$bin_dir:"*) ;;
+        *) echo "Add $bin_dir to your PATH to use the 'camel' command." ;;
+    esac
+}
+
 # Records whether this install is pinned, so `camel self-update` can honour 
it: an exact X.Y.Z pin refuses to
 # move, an X.Y or LTS pin only moves within that selector. The selector the 
user asked for is written, not the
 # version it resolved to, so an exact pin is unchanged and a selector pin 
keeps tracking its alias. Skipped when
@@ -267,7 +430,7 @@ update_pin_state() {
     fi
 }
 
-usage="Usage: install.sh [--version X.Y.Z|X.Y|LTS]"
+usage="Usage: install.sh [--version X.Y.Z|X.Y|LTS|X.Y.Z-SNAPSHOT|SNAPSHOT]"
 requested_version=""
 selector_kind=""
 while [ $# -gt 0 ]; do
@@ -284,7 +447,8 @@ while [ $# -gt 0 ]; do
 done
 
 if [ -n "$requested_version" ]; then
-    is_valid_selector || fail "invalid --version value: $requested_version 
(expected X.Y.Z, X.Y or LTS)"
+    is_valid_selector \
+        || fail "invalid --version value: $requested_version (expected X.Y.Z, 
X.Y, LTS, X.Y.Z-SNAPSHOT or SNAPSHOT)"
 fi
 
 staging_dir="$camel_cli_root/.staging.$$"
@@ -297,6 +461,13 @@ mkdir -p "$camel_cli_root"
 rm -rf "$staging_dir"
 mkdir -m 700 "$staging_dir"
 
+case "$selector_kind" in
+    snapshot | nightly)
+        install_nightly
+        exit 0
+        ;;
+esac
+
 case "$selector_kind" in
     lts) manifest_url="$manifest_base_url/lts.properties" ;;
     exact | line) 
manifest_url="$manifest_base_url/$requested_version.properties" ;;
@@ -349,10 +520,6 @@ verify_staged "$staged_root_dir/bin/camel.sh"
 
 activate "$version" "$staged_root_dir"
 update_pin_state
-
-case ":$PATH:" in
-    *":$bin_dir:"*) ;;
-    *) echo "Add $bin_dir to your PATH to use the 'camel' command." ;;
-esac
+print_path_hint
 
 echo "Installed Camel CLI $version to $data_root/$version"
diff --git 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java
 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java
index 8c6c8abe585f..0db8f4f0028f 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcher.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.dsl.jbang.launcher.selfupdate;
 
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.net.ProxySelector;
 import java.net.URI;
@@ -30,16 +31,31 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import org.apache.camel.dsl.jbang.run.common.VersionHelper;
+
 /**
  * Fetches and parses the website-installer manifest ({@code 
latest.properties}/{@code X.Y.Z.properties}). Mirrors
  * {@code install.sh}'s {@code parse_manifest} exactly, in Java. Used only for 
the pre-flight version compare that
  * neither {@code install.sh} nor {@code install.ps1} perform on their own -- 
the actual archive download, checksum
  * verification, and extraction happen inside whichever of those two scripts 
{@code SelfUpdateCommand} delegates to.
+ * <p>
+ * For the nightly builds (see {@link Nightly}) it reads the {@code 
maven-metadata.xml} of the Apache snapshot
+ * repository instead, the same files {@code install.sh} resolves them from.
  */
 public final class ManifestFetcher implements AutoCloseable {
 
     private static final String DEFAULT_MANIFEST_BASE_URL = 
"https://camel.apache.org/camel-cli/releases";;
     private static final String DEFAULT_MAVEN_BASE_URL = 
"https://repo1.maven.org/maven2/org/apache/camel/camel-launcher";;
+    private static final String DEFAULT_SNAPSHOT_BASE_URL
+            = 
"https://repository.apache.org/content/repositories/snapshots/org/apache/camel/camel-launcher";;
     private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
 
     public record Manifest(String version, String tarSha256, String zipSha256) 
{
@@ -47,10 +63,16 @@ public final class ManifestFetcher implements AutoCloseable 
{
 
     private final String manifestBaseUrl;
     private final String mavenBaseUrl;
+    private final String snapshotBaseUrl;
     private final HttpClient httpClient;
 
     public ManifestFetcher(String manifestBaseUrl, String mavenBaseUrl) {
-        this(manifestBaseUrl, mavenBaseUrl, 
HttpClient.newBuilder().proxy(ProxySelector.getDefault()).build());
+        this(manifestBaseUrl, mavenBaseUrl, DEFAULT_SNAPSHOT_BASE_URL);
+    }
+
+    public ManifestFetcher(String manifestBaseUrl, String mavenBaseUrl, String 
snapshotBaseUrl) {
+        this(manifestBaseUrl, mavenBaseUrl, snapshotBaseUrl,
+             
HttpClient.newBuilder().proxy(ProxySelector.getDefault()).build());
     }
 
     // Visible for testing: SelfUpdateIntegrationTest injects an HttpClient 
whose SSLContext trusts the
@@ -58,22 +80,30 @@ public final class ManifestFetcher implements AutoCloseable 
{
     // (used by the two-arg constructor above) has no way to know about a 
test-only CA. Production callers
     // only ever use the two-arg constructor.
     public ManifestFetcher(String manifestBaseUrl, String mavenBaseUrl, 
HttpClient httpClient) {
+        this(manifestBaseUrl, mavenBaseUrl, DEFAULT_SNAPSHOT_BASE_URL, 
httpClient);
+    }
+
+    public ManifestFetcher(String manifestBaseUrl, String mavenBaseUrl, String 
snapshotBaseUrl, HttpClient httpClient) {
         this.manifestBaseUrl = manifestBaseUrl;
         this.mavenBaseUrl = mavenBaseUrl;
+        this.snapshotBaseUrl = snapshotBaseUrl;
         this.httpClient = httpClient;
     }
 
     /**
-     * Production wiring: {@code CAMEL_SELF_UPDATE_MANIFEST_BASE_URL}/{@code 
CAMEL_SELF_UPDATE_MAVEN_BASE_URL} override
-     * the defaults, mirroring {@code install.sh}'s {@code CAMEL_INSTALL_*} 
test seams under a distinct name so
-     * overriding one never accidentally affects the other. Production 
installs never set either.
+     * Production wiring: {@code CAMEL_SELF_UPDATE_MANIFEST_BASE_URL}/{@code 
CAMEL_SELF_UPDATE_MAVEN_BASE_URL}/
+     * {@code CAMEL_SELF_UPDATE_SNAPSHOT_BASE_URL} override the defaults, 
mirroring {@code install.sh}'s
+     * {@code CAMEL_INSTALL_*} test seams under a distinct name so overriding 
one never accidentally affects the other.
+     * Production installs never set either.
      */
     public static ManifestFetcher fromEnvironment() {
         String manifestBaseUrl = 
System.getenv().getOrDefault("CAMEL_SELF_UPDATE_MANIFEST_BASE_URL",
                 DEFAULT_MANIFEST_BASE_URL);
         String mavenBaseUrl = 
System.getenv().getOrDefault("CAMEL_SELF_UPDATE_MAVEN_BASE_URL",
                 DEFAULT_MAVEN_BASE_URL);
-        return new ManifestFetcher(manifestBaseUrl, mavenBaseUrl);
+        String snapshotBaseUrl = 
System.getenv().getOrDefault("CAMEL_SELF_UPDATE_SNAPSHOT_BASE_URL",
+                DEFAULT_SNAPSHOT_BASE_URL);
+        return new ManifestFetcher(manifestBaseUrl, mavenBaseUrl, 
snapshotBaseUrl);
     }
 
     /**
@@ -123,7 +153,33 @@ public final class ManifestFetcher implements 
AutoCloseable {
         return parse(get(url, null));
     }
 
+    /**
+     * The snapshot version of main: the highest {@code X.Y.Z-SNAPSHOT} in the 
snapshot repository (the maintenance
+     * branches publish nightly builds too, under lower versions).
+     */
+    public String fetchLatestSnapshotVersion() {
+        String url = snapshotBaseUrl + "/maven-metadata.xml";
+        return parseLatestSnapshotVersion(get(url, "no nightly builds at " + 
url, "snapshot metadata"));
+    }
+
+    /**
+     * The newest nightly build of {@code snapshotVersion} ({@code 
X.Y.Z-SNAPSHOT}), such as
+     * {@code 4.24.0-20261108.143653-24}.
+     */
+    public String fetchNewestBuild(String snapshotVersion) {
+        if (!Nightly.isSnapshotVersion(snapshotVersion)) {
+            throw new IllegalArgumentException("not a snapshot version 
(expected X.Y.Z-SNAPSHOT): " + snapshotVersion);
+        }
+        String url = snapshotBaseUrl + "/" + snapshotVersion + 
"/maven-metadata.xml";
+        return parseNewestBuild(get(url, "no nightly builds of " + 
snapshotVersion + " at " + url, "snapshot metadata"),
+                snapshotVersion);
+    }
+
     private byte[] get(String url, String notFoundMessage) {
+        return get(url, notFoundMessage, "manifest");
+    }
+
+    private byte[] get(String url, String notFoundMessage, String what) {
         try {
             HttpRequest request = 
HttpRequest.newBuilder(URI.create(url)).timeout(CONNECT_TIMEOUT).build();
             HttpResponse<byte[]> response = httpClient.send(request, 
HttpResponse.BodyHandlers.ofByteArray());
@@ -132,17 +188,17 @@ public final class ManifestFetcher implements 
AutoCloseable {
             }
             if (response.statusCode() != 200) {
                 throw new SelfUpdateException(
-                        "failed to download manifest from " + url + " (HTTP "
+                        "failed to download " + what + " from " + url + " 
(HTTP "
                                               + response.statusCode() + ")");
             }
             return response.body();
         } catch (SelfUpdateException e) {
             throw e;
         } catch (IOException e) {
-            throw new SelfUpdateException("failed to download manifest from " 
+ url, e);
+            throw new SelfUpdateException("failed to download " + what + " 
from " + url, e);
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            throw new SelfUpdateException("failed to download manifest from " 
+ url, e);
+            throw new SelfUpdateException("failed to download " + what + " 
from " + url, e);
         }
     }
 
@@ -210,6 +266,79 @@ public final class ManifestFetcher implements 
AutoCloseable {
         return new Manifest(version, values.get("tar_sha256"), 
values.get("zip_sha256"));
     }
 
+    // Package-visible for unit testing. The highest X.Y.Z-SNAPSHOT of 
<versions>; other entries are ignored.
+    static String parseLatestSnapshotVersion(byte[] content) {
+        String latest = null;
+        NodeList versions = parseXml(content).getElementsByTagName("version");
+        for (int i = 0; i < versions.getLength(); i++) {
+            String version = versions.item(i).getTextContent().strip();
+            if (Nightly.isSnapshotVersion(version)
+                    && (latest == null || VersionHelper.compare(base(version), 
base(latest)) > 0)) {
+                latest = version;
+            }
+        }
+        if (latest == null) {
+            throw new SelfUpdateException("the snapshot repository lists no 
X.Y.Z-SNAPSHOT version");
+        }
+        return latest;
+    }
+
+    // Package-visible for unit testing. The <value> of the bin tar.gz 
<snapshotVersion> (Maven 3 metadata), or else
+    // the <timestamp>-<buildNumber> of the <snapshot> (Maven 2 metadata, 
which Nexus 2 serves to a client it does not
+    // take for Maven 3, such as the JDK HTTP client), as all the files of one 
deploy share them. It must be a nightly
+    // build of snapshotVersion: it is used in a directory name and a download 
URL.
+    static String parseNewestBuild(byte[] content, String snapshotVersion) {
+        Document document = parseXml(content);
+        String build = null;
+        NodeList entries = document.getElementsByTagName("snapshotVersion");
+        for (int i = 0; i < entries.getLength() && build == null; i++) {
+            Element entry = (Element) entries.item(i);
+            if ("bin".equals(childText(entry, "classifier")) && 
"tar.gz".equals(childText(entry, "extension"))) {
+                build = String.valueOf(childText(entry, "value"));
+            }
+        }
+        if (build == null) {
+            NodeList snapshots = document.getElementsByTagName("snapshot");
+            if (snapshots.getLength() == 0) {
+                throw new SelfUpdateException("the snapshot metadata of " + 
snapshotVersion + " has no nightly build");
+            }
+            Element snapshot = (Element) snapshots.item(0);
+            build = base(snapshotVersion) + "-" + childText(snapshot, 
"timestamp") + "-" + childText(snapshot, "buildNumber");
+        }
+        if (!Nightly.isBuild(build) || 
!Nightly.snapshotVersionOf(build).equals(snapshotVersion)) {
+            throw new SelfUpdateException(
+                    "the snapshot metadata names an invalid nightly build of " 
+ snapshotVersion + ": " + build);
+        }
+        return build;
+    }
+
+    private static String base(String snapshotVersion) {
+        return snapshotVersion.substring(0, snapshotVersion.length() - 
"-SNAPSHOT".length());
+    }
+
+    private static String childText(Element parent, String name) {
+        NodeList children = parent.getElementsByTagName(name);
+        return children.getLength() > 0 ? 
children.item(0).getTextContent().strip() : null;
+    }
+
+    // The metadata comes from the network: no DTD, no external entities or 
schemas. The JDK's own parser, not
+    // whichever one the class path offers (an old Xerces is on it, which does 
not know the JAXP 1.5 properties).
+    private static Document parseXml(byte[] content) {
+        try {
+            DocumentBuilderFactory factory = 
DocumentBuilderFactory.newDefaultInstance();
+            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+            
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl";, 
true);
+            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+            factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+            factory.setXIncludeAware(false);
+            factory.setExpandEntityReferences(false);
+            DocumentBuilder builder = factory.newDocumentBuilder();
+            return builder.parse(new ByteArrayInputStream(content));
+        } catch (Exception e) {
+            throw new SelfUpdateException("the snapshot metadata is not valid: 
" + e.getMessage(), e);
+        }
+    }
+
     private static void validateSha256(String value, String label) {
         if (!value.matches("[0-9a-f]{64}")) {
             throw new SelfUpdateException(label + " is not a 64-character 
lowercase hex value");
diff --git 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Nightly.java
 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Nightly.java
new file mode 100644
index 000000000000..296f9d9f1cc9
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Nightly.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.launcher.selfupdate;
+
+import java.nio.file.Path;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.dsl.jbang.core.common.InstallDetector;
+
+/**
+ * The versions of the nightly builds of the launcher, which Apache publishes 
to its snapshot repository: a snapshot
+ * version ({@code 4.24.0-SNAPSHOT}), the newest snapshot of main ({@code 
SNAPSHOT}), and one nightly build, named as
+ * the snapshot repository names it ({@code 4.24.0-20261108.143653-24}: the 
timestamp and build number of the deploy).
+ * <p>
+ * A nightly build is not an Apache release. The web installer installs each 
nightly build in its own directory, named
+ * after it, so a command that still runs from the previous build keeps its 
files.
+ */
+public final class Nightly {
+
+    /** The selector for the newest snapshot of main. */
+    public static final String LATEST = "SNAPSHOT";
+
+    private static final Pattern SNAPSHOT_VERSION = 
Pattern.compile("([0-9]+\\.[0-9]+\\.[0-9]+)-SNAPSHOT");
+    private static final Pattern BUILD = 
Pattern.compile("([0-9]+\\.[0-9]+\\.[0-9]+)-([0-9]{8}\\.[0-9]{6})-([0-9]{1,9})");
+
+    private Nightly() {
+    }
+
+    /** Whether {@code value} is a snapshot version, {@code X.Y.Z-SNAPSHOT}. */
+    public static boolean isSnapshotVersion(String value) {
+        return value != null && SNAPSHOT_VERSION.matcher(value).matches();
+    }
+
+    /** Whether {@code value} is the selector of the newest snapshot of main, 
{@code SNAPSHOT} (case-insensitive). */
+    public static boolean isLatest(String value) {
+        return LATEST.equalsIgnoreCase(value);
+    }
+
+    /** Whether {@code value} is one nightly build, {@code 
X.Y.Z-YYYYMMDD.HHMMSS-N}. */
+    public static boolean isBuild(String value) {
+        return value != null && BUILD.matcher(value).matches();
+    }
+
+    /**
+     * Whether {@code value} asks for nightly builds: {@code SNAPSHOT}, {@code 
X.Y.Z-SNAPSHOT} or one nightly build.
+     */
+    public static boolean isNightly(String value) {
+        return isLatest(value) || isSnapshotVersion(value) || isBuild(value);
+    }
+
+    /** Normalizes a nightly selector: {@code snapshot} becomes {@code 
SNAPSHOT}, the others are kept. */
+    public static String normalize(String value) {
+        return isLatest(value) ? LATEST : value;
+    }
+
+    /** The snapshot version of a nightly build: {@code 4.24.0-SNAPSHOT} for 
{@code 4.24.0-20261108.143653-24}. */
+    public static String snapshotVersionOf(String build) {
+        Matcher m = BUILD.matcher(build);
+        if (!m.matches()) {
+            throw new IllegalArgumentException("not a nightly build: " + 
build);
+        }
+        return m.group(1) + "-SNAPSHOT";
+    }
+
+    /**
+     * Compares two nightly builds of the same snapshot version by their 
deploy (timestamp, then build number); a
+     * positive value when {@code a} is newer.
+     */
+    public static int compare(String a, String b) {
+        Matcher ma = BUILD.matcher(a);
+        Matcher mb = BUILD.matcher(b);
+        if (!ma.matches() || !mb.matches()) {
+            throw new IllegalArgumentException("not nightly builds: " + a + ", 
" + b);
+        }
+        int c = ma.group(2).compareTo(mb.group(2));
+        return c != 0 ? c : Integer.compare(Integer.parseInt(ma.group(3)), 
Integer.parseInt(mb.group(3)));
+    }
+
+    /**
+     * The nightly build the launcher runs from: the name of its directory 
under the web installer's {@code versions}
+     * directory, when that is a nightly build.
+     */
+    public static Optional<String> runningBuild(Path launcherJar, Path 
versionsRoot) {
+        return InstallDetector.nightlyBuildOf(launcherJar, versionsRoot);
+    }
+
+    /**
+     * Describes a nightly selector for messages: {@code the nightly builds of 
main}, {@code ... of 4.24.0-SNAPSHOT}.
+     */
+    public static String describe(String selector) {
+        if (isLatest(selector)) {
+            return "the nightly builds of main";
+        }
+        if (isSnapshotVersion(selector)) {
+            return "the nightly builds of " + selector;
+        }
+        return "nightly build " + selector;
+    }
+
+    /** The notice printed before a nightly build is installed. */
+    public static String notice(String build) {
+        return "Note: " + build + " is a nightly development build of Apache 
Camel, for testing only. It is not an Apache"
+               + " release, and is verified only against the SHA-1 published 
next to it in the snapshot repository.";
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Pin.java
 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Pin.java
index 77a3a9a5eb08..00faf995ad76 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Pin.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/Pin.java
@@ -21,8 +21,10 @@ import java.util.regex.Pattern;
 /**
  * The content of the web installer's {@code pinned-version} file, as written 
by {@code install.sh}/{@code install.ps1}
  * for an explicit {@code --version}/{@code -Version}: an exact release 
({@code X.Y.Z}), a release line ({@code X.Y}),
- * or the newest LTS release ({@code LTS}). Anything else is {@link 
Kind#INVALID} and treated like an exact pin, so an
- * unexpected pin file never lets {@code camel self-update} move the install.
+ * the newest LTS release ({@code LTS}), the nightly builds of a snapshot 
version ({@code X.Y.Z-SNAPSHOT}) or of main
+ * ({@code SNAPSHOT}), or one nightly build ({@code X.Y.Z-YYYYMMDD.HHMMSS-N}, 
see {@link Nightly}). Anything else is
+ * {@link Kind#INVALID} and treated like an exact pin, so an unexpected pin 
file never lets {@code camel self-update}
+ * move the install.
  */
 public record Pin(Kind kind, String value) {
 
@@ -33,6 +35,10 @@ public record Pin(Kind kind, String value) {
         EXACT,
         LINE,
         LTS,
+        /** The nightly builds of a snapshot version ({@code X.Y.Z-SNAPSHOT}) 
or of main ({@code SNAPSHOT}). */
+        SNAPSHOT,
+        /** One nightly build, which never moves, as an exact release. */
+        NIGHTLY,
         INVALID
     }
 
@@ -47,6 +53,12 @@ public record Pin(Kind kind, String value) {
         if (LINE.matcher(value).matches()) {
             return new Pin(Kind.LINE, value);
         }
+        if (Nightly.isLatest(value) || Nightly.isSnapshotVersion(value)) {
+            return new Pin(Kind.SNAPSHOT, Nightly.normalize(value));
+        }
+        if (Nightly.isBuild(value)) {
+            return new Pin(Kind.NIGHTLY, value);
+        }
         return new Pin(Kind.INVALID, value);
     }
 
@@ -58,6 +70,14 @@ public record Pin(Kind kind, String value) {
         return kind == Kind.LINE || kind == Kind.LTS;
     }
 
+    /**
+     * Whether this pin follows the nightly builds of a snapshot version or of 
main, which {@code camel self-update}
+     * resolves from the snapshot repository rather than from a published 
alias.
+     */
+    public boolean followsNightlies() {
+        return kind == Kind.SNAPSHOT;
+    }
+
     /**
      * The alias manifest name ({@code lts} or {@code X.Y}) for a {@link 
#tracksAlias() tracking} pin.
      */
@@ -70,14 +90,17 @@ public record Pin(Kind kind, String value) {
     }
 
     /**
-     * Whether {@code version} (a valid X.Y.Z) satisfies this pin: any release 
for LTS, a release of the line for X.Y,
-     * that exact release otherwise.
+     * Whether {@code version} (a valid X.Y.Z, or a nightly build for a 
snapshot pin) satisfies this pin: any release
+     * for LTS, a release of the line for X.Y, a nightly build of the snapshot 
version (or any, for {@code SNAPSHOT})
+     * for a snapshot pin, that exact release or nightly build otherwise.
      */
     public boolean accepts(String version) {
         return switch (kind) {
             case LTS -> EXACT.matcher(version).matches();
             case LINE -> EXACT.matcher(version).matches() && 
version.startsWith(value + ".");
-            case EXACT -> value.equals(version);
+            case EXACT, NIGHTLY -> value.equals(version);
+            case SNAPSHOT -> Nightly.isBuild(version)
+                    && (Nightly.LATEST.equals(value) || 
Nightly.snapshotVersionOf(version).equals(value));
             default -> false;
         };
     }
@@ -89,6 +112,7 @@ public record Pin(Kind kind, String value) {
         return switch (kind) {
             case LTS -> "LTS";
             case LINE -> "the " + value + " release line";
+            case SNAPSHOT -> Nightly.describe(value);
             default -> "version " + value;
         };
     }
diff --git 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/SelfUpdateCommand.java
 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/SelfUpdateCommand.java
index a0b7b3cc3ba2..7a59a4c9345b 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/SelfUpdateCommand.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/SelfUpdateCommand.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.dsl.jbang.launcher.selfupdate;
 
+import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
@@ -42,12 +43,18 @@ import picocli.CommandLine;
                              "%nExamples:",
                              "  camel self-update",
                              "  camel self-update --version 4.23.0",
+                             "  camel self-update --version SNAPSHOT",
                              "  camel self-update --check",
                              "%nAn install pinned with the web installer's 
--version X.Y.Z never moves; one pinned with",
-                             "--version X.Y or --version LTS moves to the 
newest release of that line, or the newest LTS." })
+                             "--version X.Y or --version LTS moves to the 
newest release of that line, or the newest LTS.",
+                             "%n--version SNAPSHOT (main) or X.Y.Z-SNAPSHOT 
installs the newest nightly build from the Apache",
+                             "snapshot repository, and pins it, so camel 
self-update follows the nightly builds. Nightly",
+                             "builds are development builds for testing, not 
Apache releases." })
 public class SelfUpdateCommand extends CamelCommand {
 
-    @CommandLine.Option(names = { "--version" }, description = "Install a 
specific version instead of the latest")
+    @CommandLine.Option(names = { "--version" },
+                        description = "Install a specific version instead of 
the latest: X.Y.Z, or the nightly builds of"
+                                      + " SNAPSHOT (main) or X.Y.Z-SNAPSHOT")
     String version;
 
     @CommandLine.Option(names = { "--check" }, description = "Only report 
whether a newer version is available")
@@ -86,12 +93,12 @@ public class SelfUpdateCommand extends CamelCommand {
         // only within its selector, so an explicit --version, which could 
leave it, is refused as well.
         Optional<String> pinned = InstallDetector.pinnedVersion();
         Pin pin = pinned.map(Pin::parse).orElse(null);
-        if (pin != null && !pin.tracksAlias()) {
+        if (pin != null && !pin.tracksAlias() && !pin.followsNightlies()) {
             printer().printErr("this install is pinned to version " + 
pinned.get() + " - remove "
                                + InstallDetector.pinnedVersionFile() + " to 
resume tracking new releases");
             return 1;
         }
-        if (pin != null && version != null) {
+        if (pin != null && pin.tracksAlias() && version != null) {
             // Removing the pin file would make the install track the latest 
release, which is not what asking for
             // one version means, so point at the installer, which replaces 
the selector pin with an exact one.
             printer().printErr(selectorPinRefusal(pin, version));
@@ -99,6 +106,18 @@ public class SelfUpdateCommand extends CamelCommand {
         }
 
         ManifestFetcher fetcher = fetcherOverride != null ? fetcherOverride : 
ManifestFetcher.fromEnvironment();
+        if (version != null && Nightly.isNightly(version)) {
+            return updateToNightly(fetcher, info, Nightly.normalize(version), 
true);
+        }
+        if (version == null && pin != null && pin.followsNightlies()) {
+            return updateToNightly(fetcher, info, pin.value(), false);
+        }
+
+        // From here on a release. An explicit --version leaves a nightly pin 
(the pin is removed once installed).
+        boolean leavesNightlies = pin != null && pin.followsNightlies();
+        if (leavesNightlies) {
+            pin = null;
+        }
         ManifestFetcher.Manifest manifest;
         try {
             if (pin != null) {
@@ -147,7 +166,75 @@ public class SelfUpdateCommand extends CamelCommand {
         // script's own manifest fetch below, and the script would silently 
install a version this command never
         // actually decided on. For an X.Y or LTS pin this is the version the 
alias resolved to; the delegated run
         // (CAMEL_INSTALL_SELF_UPDATE=true) leaves the selector in the pin 
file untouched.
-        return runInstaller(manifest.version());
+        int exit = runInstaller(manifest.version());
+        if (exit == 0 && leavesNightlies) {
+            return writePin(null);
+        }
+        return exit;
+    }
+
+    // Installs the newest nightly build of the selector (SNAPSHOT or 
X.Y.Z-SNAPSHOT), or the nightly build it names.
+    // An explicit --version pins the selector, so a later camel self-update 
follows the nightly builds; following the
+    // pin (explicit=false) only installs a newer build.
+    private Integer updateToNightly(
+            ManifestFetcher fetcher, InstallDetector.InstallInfo info, String 
selector, boolean explicit) {
+        String build;
+        try {
+            if (Nightly.isBuild(selector)) {
+                build = selector;
+            } else {
+                String snapshotVersion = Nightly.isLatest(selector) ? 
fetcher.fetchLatestSnapshotVersion() : selector;
+                build = fetcher.fetchNewestBuild(snapshotVersion);
+            }
+        } catch (SelfUpdateException e) {
+            printer().printErr(e.getMessage());
+            return 1;
+        }
+
+        Optional<String> running = Nightly.runningBuild(info.location(), 
InstallDetector.webInstallerVersionsRoot());
+        boolean same = running.isPresent() && running.get().equals(build);
+        boolean newer = running.isEmpty()
+                || 
!Nightly.snapshotVersionOf(running.get()).equals(Nightly.snapshotVersionOf(build))
+                || Nightly.compare(build, running.get()) > 0;
+        String current = running.orElseGet(() -> new 
DefaultCamelCatalog().getCatalogVersion());
+
+        if (checkOnly) {
+            printer().println(same || !newer
+                    ? "Camel CLI is already on the newest nightly build for " 
+ Nightly.describe(selector) + " (" + current
+                      + ")"
+                    : "A new nightly build is available (" + current + " -> " 
+ build + ")");
+            return 0;
+        }
+        if (same || (!newer && !explicit)) {
+            printer().println(
+                    "Camel CLI is already on the newest nightly build for " + 
Nightly.describe(selector) + " (" + current
+                              + ")");
+            return explicit && !Nightly.isBuild(selector) ? writePin(selector) 
: 0;
+        }
+
+        printer().println(Nightly.notice(build));
+        int exit = runInstaller(build);
+        if (exit == 0 && explicit && !Nightly.isBuild(selector)) {
+            return writePin(selector);
+        }
+        return exit;
+    }
+
+    // Writes (or, for null, removes) the pin of the web installer, as 
install.sh does for an explicit --version.
+    private int writePin(String selector) {
+        Path file = InstallDetector.pinnedVersionFile();
+        try {
+            if (selector == null) {
+                Files.deleteIfExists(file);
+            } else {
+                Files.createDirectories(file.getParent());
+                Files.writeString(file, selector + "\n", 
StandardCharsets.UTF_8);
+            }
+            return 0;
+        } catch (IOException e) {
+            printer().printErr("installed, but failed to update the pin in " + 
file, e);
+            return 1;
+        }
     }
 
     private Integer runInstaller(String targetVersion) {
diff --git 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java
 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java
index 88b2ae01ef5a..8fd154919ab8 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/selfupdate/UpdateChecker.java
@@ -60,7 +60,8 @@ public final class UpdateChecker {
         }
         // An exact pin refuses `camel self-update` outright (see 
SelfUpdateCommand); telling the user to run it
         // here would be advice they can't actually follow. An X.Y or LTS pin 
can move within its selector, so
-        // check the matching alias instead of latest.properties.
+        // check the matching alias instead of latest.properties. A pin to 
nightly builds gets no notice: a new
+        // nightly build lands every day, and camel self-update installs the 
newest one when asked.
         Pin pin = InstallDetector.pinnedVersion().map(Pin::parse).orElse(null);
         if (pin != null && !pin.tracksAlias()) {
             return;
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/SelfUpdateIntegrationTest.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/SelfUpdateIntegrationTest.java
index 497ace4e8297..81d850966785 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/SelfUpdateIntegrationTest.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/SelfUpdateIntegrationTest.java
@@ -122,8 +122,8 @@ class SelfUpdateIntegrationTest {
 
     private int run(WebsiteInstallerFixture fixture, Path home, Path 
xdgDataHome, String... args) throws Exception {
         HttpClient httpClient = trustingHttpClient(fixture);
-        ManifestFetcher fetcher
-                = new ManifestFetcher(fixture.baseUrl() + 
"/camel-cli/releases", fixture.mavenUrl(), httpClient);
+        ManifestFetcher fetcher = new ManifestFetcher(
+                fixture.baseUrl() + "/camel-cli/releases", fixture.mavenUrl(), 
fixture.snapshotUrl(), httpClient);
         InstallScriptFetcher scriptFetcher = new 
InstallScriptFetcher(fixture.baseUrl(), httpClient);
         Map<String, String> installerEnv = fixture.environment(home);
         SelfUpdateCommand cmd
@@ -470,4 +470,117 @@ class SelfUpdateIntegrationTest {
             assertThat(printer.getOutput()).contains("Homebrew");
         }
     }
+
+    private static final String OLD_NIGHTLY = "9.9.0-20261101.010000-1";
+
+    @Test
+    void installsNewestNightlyOfMainAndPinsSnapshot(@TempDir Path temp) throws 
Exception {
+        try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+            Path home = Files.createDirectories(temp.resolve("home"));
+            Path xdgDataHome = home.resolve(".local/share");
+            markRunningAsWebInstaller(home, xdgDataHome, "1.0.0");
+            publishRealInstallSh(fixture);
+            WebsiteInstallTest.publishSnapshotVersions(fixture, 
"9.9.0-SNAPSHOT", "9.8.1-SNAPSHOT");
+            WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", 
WebsiteInstallTest.NIGHTLY, null);
+
+            int exit = run(fixture, home, xdgDataHome, "--version", 
"snapshot");
+
+            assertThat(exit).isZero();
+            assertThat(printer.getOutput()).contains("Installed Camel CLI " + 
WebsiteInstallTest.NIGHTLY);
+            // once: install.sh leaves the note to camel self-update
+            assertThat(printer.getOutput().split("is a nightly development 
build of Apache Camel", -1)).hasSize(2);
+            assertThat(xdgDataHome.resolve("camel-cli/versions/" + 
WebsiteInstallTest.NIGHTLY)).isDirectory();
+            
assertThat(xdgDataHome.resolve("camel-cli/pinned-version")).hasContent("SNAPSHOT");
+        }
+    }
+
+    @Test
+    void snapshotPinMovesToNewerNightly(@TempDir Path temp) throws Exception {
+        try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+            Path home = Files.createDirectories(temp.resolve("home"));
+            Path xdgDataHome = home.resolve(".local/share");
+            markRunningAsWebInstaller(home, xdgDataHome, OLD_NIGHTLY);
+            Path pinFile = pin(xdgDataHome, "9.9.0-SNAPSHOT");
+            publishRealInstallSh(fixture);
+            WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", 
WebsiteInstallTest.NIGHTLY, null);
+
+            int exit = run(fixture, home, xdgDataHome);
+
+            assertThat(exit).isZero();
+            assertThat(xdgDataHome.resolve("camel-cli/versions/" + 
WebsiteInstallTest.NIGHTLY)).isDirectory();
+            assertThat(pinFile).hasContent("9.9.0-SNAPSHOT");
+        }
+    }
+
+    @Test
+    void snapshotPinOnNewestNightlySkipsDelegation(@TempDir Path temp) throws 
Exception {
+        try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+            Path home = Files.createDirectories(temp.resolve("home"));
+            Path xdgDataHome = home.resolve(".local/share");
+            markRunningAsWebInstaller(home, xdgDataHome, 
WebsiteInstallTest.NIGHTLY);
+            pin(xdgDataHome, "SNAPSHOT");
+            // install.sh is deliberately not published: delegating would fail
+            WebsiteInstallTest.publishSnapshotVersions(fixture, 
"9.9.0-SNAPSHOT");
+            WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", 
WebsiteInstallTest.NIGHTLY, null);
+
+            int exit = run(fixture, home, xdgDataHome);
+
+            assertThat(exit).isZero();
+            assertThat(printer.getOutput()).contains("already on the newest 
nightly build for the nightly builds of main");
+        }
+    }
+
+    @Test
+    void checksForNewerNightlyWithoutDelegating(@TempDir Path temp) throws 
Exception {
+        try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+            Path home = Files.createDirectories(temp.resolve("home"));
+            Path xdgDataHome = home.resolve(".local/share");
+            markRunningAsWebInstaller(home, xdgDataHome, OLD_NIGHTLY);
+            pin(xdgDataHome, "9.9.0-SNAPSHOT");
+            WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", 
WebsiteInstallTest.NIGHTLY, null);
+
+            int exit = run(fixture, home, xdgDataHome, "--check");
+
+            assertThat(exit).isZero();
+            assertThat(printer.getOutput())
+                    .contains("A new nightly build is available (" + 
OLD_NIGHTLY + " -> " + WebsiteInstallTest.NIGHTLY + ")");
+            assertThat(xdgDataHome.resolve("camel-cli/versions/" + 
WebsiteInstallTest.NIGHTLY)).doesNotExist();
+        }
+    }
+
+    @Test
+    void explicitReleaseLeavesTheNightlies(@TempDir Path temp) throws 
Exception {
+        try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+            Path home = Files.createDirectories(temp.resolve("home"));
+            Path xdgDataHome = home.resolve(".local/share");
+            markRunningAsWebInstaller(home, xdgDataHome, 
WebsiteInstallTest.NIGHTLY);
+            Path pinFile = pin(xdgDataHome, "SNAPSHOT");
+            publishRealInstallSh(fixture);
+            WebsiteInstallTest.publishVersion(fixture, NEWER_VERSION);
+
+            int exit = run(fixture, home, xdgDataHome, "--version", 
NEWER_VERSION);
+
+            assertThat(exit).isZero();
+            assertThat(xdgDataHome.resolve("camel-cli/versions/" + 
NEWER_VERSION)).isDirectory();
+            // back to following the releases
+            assertThat(pinFile).doesNotExist();
+        }
+    }
+
+    @Test
+    void exactPinRefusesNightlies(@TempDir Path temp) throws Exception {
+        try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+            Path home = Files.createDirectories(temp.resolve("home"));
+            Path xdgDataHome = home.resolve(".local/share");
+            markRunningAsWebInstaller(home, xdgDataHome, "1.0.0");
+            pin(xdgDataHome, "1.0.0");
+            WebsiteInstallTest.publishNightly(fixture, "9.9.0-SNAPSHOT", 
WebsiteInstallTest.NIGHTLY, null);
+
+            int exit = run(fixture, home, xdgDataHome, "--version", 
"9.9.0-SNAPSHOT");
+
+            assertThat(exit).isEqualTo(1);
+            assertThat(printer.getOutput()).contains("pinned to version 
1.0.0");
+            assertThat(xdgDataHome.resolve("camel-cli/versions/" + 
WebsiteInstallTest.NIGHTLY)).doesNotExist();
+        }
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java
index 2be6dd700b60..8c76a5677b7b 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallTest.java
@@ -26,10 +26,12 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.security.MessageDigest;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.HexFormat;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -97,7 +99,63 @@ class WebsiteInstallTest {
     }
 
     static Stream<String> malformedSelectors() {
-        return Stream.of("2", "2.", ".2", "2..1", "1.2.3.4", "1.2.3-SNAPSHOT", 
"v2.1", "latest", "LTS2", "lt", "2.x");
+        return Stream.of("2", "2.", ".2", "2..1", "1.2.3.4", "1.2.3-snapshot", 
"v2.1", "latest", "LTS2", "lt", "2.x");
+    }
+
+    // install.sh only: install.ps1 does not install nightly builds yet.
+    static Stream<String> malformedNightlySelectors() {
+        return Stream.of("1.2.3-SNAPSHOT1", "1.2-SNAPSHOT", "SNAPSHOTS", 
"1.2.3-20261108.143653", "1.2.3-20261108-24",
+                "1.2.3-20261108.143653-x", "1.2.3-20261108.143653-24/..", 
"1.2.3-20261108.143653-1234567890");
+    }
+
+    static final String NIGHTLY = "9.9.0-20261108.143653-24";
+
+    /**
+     * Publishes nightly build {@code build} of {@code snapshotVersion} as the 
Apache snapshot repository does: the
+     * archive (which unpacks to camel-launcher-X.Y.Z-SNAPSHOT), its {@code 
.sha1}, and the version metadata naming it
+     * as the newest build. {@code sha1} overrides the published checksum when 
not null.
+     */
+    static void publishNightly(
+            WebsiteInstallerFixture fixture, String snapshotVersion, String 
build, String sha1)
+            throws Exception {
+        Path tar = fixture.safeTar(snapshotVersion);
+        String base = "/snapshots/org/apache/camel/camel-launcher/" + 
snapshotVersion;
+        fixture.publish(base + "/camel-launcher-" + build + "-bin.tar.gz", 
Files.readAllBytes(tar));
+        String checksum = sha1 != null
+                ? sha1 : 
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-1").digest(Files.readAllBytes(tar)));
+        fixture.publish(base + "/camel-launcher-" + build + 
"-bin.tar.gz.sha1", checksum.getBytes(StandardCharsets.UTF_8));
+        String metadata = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+                          + "<metadata modelVersion=\"1.1.0\">\n"
+                          + "  <version>" + snapshotVersion + "</version>\n"
+                          + "  <versioning>\n"
+                          + "    <snapshotVersions>\n"
+                          + "      <snapshotVersion>\n"
+                          + "        <extension>pom</extension>\n"
+                          + "        <value>" + build + "</value>\n"
+                          + "      </snapshotVersion>\n"
+                          + "      <snapshotVersion>\n"
+                          + "        <classifier>bin</classifier>\n"
+                          + "        <extension>tar.gz</extension>\n"
+                          + "        <value>" + build + "</value>\n"
+                          + "      </snapshotVersion>\n"
+                          + "    </snapshotVersions>\n"
+                          + "  </versioning>\n"
+                          + "</metadata>\n";
+        fixture.publish(base + "/maven-metadata.xml", 
metadata.getBytes(StandardCharsets.UTF_8));
+    }
+
+    /** Publishes the root metadata of the snapshot repository, listing these 
snapshot versions. */
+    static void publishSnapshotVersions(WebsiteInstallerFixture fixture, 
String... snapshotVersions) {
+        StringBuilder metadata = new StringBuilder("<?xml version=\"1.0\" 
encoding=\"UTF-8\"?>\n<metadata>\n  <versioning>\n");
+        // <latest> names the last deploy, which is not the highest version
+        metadata.append("    
<latest>").append(snapshotVersions[snapshotVersions.length - 
1]).append("</latest>\n");
+        metadata.append("    <versions>\n");
+        for (String version : snapshotVersions) {
+            metadata.append("      
<version>").append(version).append("</version>\n");
+        }
+        metadata.append("    </versions>\n  </versioning>\n</metadata>\n");
+        
fixture.publish("/snapshots/org/apache/camel/camel-launcher/maven-metadata.xml",
+                metadata.toString().getBytes(StandardCharsets.UTF_8));
     }
 
     static void publishRelease(WebsiteInstallerFixture fixture, String 
version, Path tar, Path zip) throws Exception {
@@ -504,6 +562,188 @@ class WebsiteInstallTest {
             }
         }
 
+        private static void assertNightlyInstalled(Path home, String build, 
String snapshotVersion) throws Exception {
+            Path shim = home.resolve(".local/bin/camel");
+            Path versionDir = home.resolve(".local/share/camel-cli/versions/" 
+ build);
+            assertTrue(Files.isSymbolicLink(shim), "expected a symlink at " + 
shim);
+            assertEquals(versionDir.resolve("bin/camel.sh"), 
Files.readSymbolicLink(shim));
+            String output = WebsiteInstallerFixture.execute(
+                    new ProcessBuilder("/bin/sh", shim.toString(), 
"version").redirectErrorStream(true)).stdout();
+            assertThat(output).contains("Camel " + snapshotVersion);
+        }
+
+        @Test
+        void installsNewestNightlyOfMainAndPinsSnapshot(@TempDir Path temp) 
throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                // main is the highest snapshot version, not the one deployed 
last
+                publishSnapshotVersions(fixture, "9.9.0-SNAPSHOT", 
"9.8.1-SNAPSHOT");
+                publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, null);
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
"snapshot");
+
+                assertEquals(0, r.exit(), r.stderr());
+                assertThat(r.stdout()).contains("is a nightly development 
build of Apache Camel")
+                        .contains("Installed Camel CLI " + NIGHTLY + " (a 
nightly build of 9.9.0-SNAPSHOT)");
+                assertNightlyInstalled(home, NIGHTLY, "9.9.0-SNAPSHOT");
+                assertThat(pinnedVersionFile(home)).hasContent("SNAPSHOT");
+            }
+        }
+
+        @Test
+        void installsNewestNightlyOfSnapshotVersionAndPinsIt(@TempDir Path 
temp) throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                publishNightly(fixture, "9.8.1-SNAPSHOT", 
"9.8.1-20261107.010203-7", null);
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
"9.8.1-SNAPSHOT");
+
+                assertEquals(0, r.exit(), r.stderr());
+                assertNightlyInstalled(home, "9.8.1-20261107.010203-7", 
"9.8.1-SNAPSHOT");
+                
assertThat(pinnedVersionFile(home)).hasContent("9.8.1-SNAPSHOT");
+            }
+        }
+
+        // what camel self-update passes on, after resolving the newest build 
itself
+        @Test
+        void installsOneNightlyBuild(@TempDir Path temp) throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, null);
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
NIGHTLY);
+
+                assertEquals(0, r.exit(), r.stderr());
+                assertNightlyInstalled(home, NIGHTLY, "9.9.0-SNAPSHOT");
+                assertThat(pinnedVersionFile(home)).hasContent(NIGHTLY);
+            }
+        }
+
+        // Nexus 2 serves Maven 2 metadata to a client it does not take for 
Maven 3 (wget, for one)
+        @Test
+        void installsNightlyFromMaven2Metadata(@TempDir Path temp) throws 
Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, null);
+                String metadata = "<?xml version=\"1.0\" 
encoding=\"UTF-8\"?>\n"
+                                  + "<metadata>\n"
+                                  + "  <version>9.9.0-SNAPSHOT</version>\n"
+                                  + "  <versioning>\n"
+                                  + "    <snapshot>\n"
+                                  + "      
<timestamp>20261108.143653</timestamp>\n"
+                                  + "      <buildNumber>24</buildNumber>\n"
+                                  + "    </snapshot>\n"
+                                  + "    
<lastUpdated>20261108143653</lastUpdated>\n"
+                                  + "  </versioning>\n"
+                                  + "</metadata>\n";
+                
fixture.publish("/snapshots/org/apache/camel/camel-launcher/9.9.0-SNAPSHOT/maven-metadata.xml",
+                        metadata.getBytes(StandardCharsets.UTF_8));
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
"9.9.0-SNAPSHOT");
+
+                assertEquals(0, r.exit(), r.stderr());
+                assertNightlyInstalled(home, NIGHTLY, "9.9.0-SNAPSHOT");
+            }
+        }
+
+        @Test
+        void rejectsNightlyWithWrongSha1(@TempDir Path temp) throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, 
"0".repeat(40));
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
"9.9.0-SNAPSHOT");
+
+                assertNotEquals(0, r.exit());
+                assertThat(r.stderr()).contains("checksum mismatch for 
downloaded archive");
+                assertFalse(Files.exists(home.resolve(".local/bin/camel")));
+                
assertFalse(Files.exists(home.resolve(".local/share/camel-cli/versions/" + 
NIGHTLY)));
+                assertThat(pinnedVersionFile(home)).doesNotExist();
+            }
+        }
+
+        @ParameterizedTest(name = "{0}")
+        
@MethodSource("org.apache.camel.dsl.jbang.launcher.WebsiteInstallTest#malformedNightlySelectors")
+        void rejectsMalformedNightlySelector(String selector, @TempDir Path 
temp) throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
selector);
+
+                assertNotEquals(0, r.exit());
+                assertThat(r.stderr()).contains("invalid --version value");
+                assertFalse(Files.exists(home.resolve(".local/bin/camel")));
+            }
+        }
+
+        // The build name from the metadata becomes a directory name and a 
URL: anything else than a nightly build of
+        // the requested snapshot version is refused before downloading.
+        @ParameterizedTest(name = "{0}")
+        
@MethodSource("org.apache.camel.dsl.jbang.launcher.WebsiteInstallTest#malformedNightlySelectors")
+        void rejectsMalformedBuildInMetadata(String build, @TempDir Path temp) 
throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                publishNightly(fixture, "9.9.0-SNAPSHOT", build, null);
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
"9.9.0-SNAPSHOT");
+
+                assertNotEquals(0, r.exit());
+                assertThat(r.stderr()).contains("the snapshot metadata names 
an invalid nightly build of 9.9.0-SNAPSHOT");
+                assertFalse(Files.exists(home.resolve(".local/bin/camel")));
+            }
+        }
+
+        @Test
+        void rejectsBuildOfAnotherSnapshotVersionInMetadata(@TempDir Path 
temp) throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                publishNightly(fixture, "9.9.0-SNAPSHOT", 
"9.8.1-20261107.010203-7", null);
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
"9.9.0-SNAPSHOT");
+
+                assertNotEquals(0, r.exit());
+                assertThat(r.stderr()).contains("invalid nightly build of 
9.9.0-SNAPSHOT: 9.8.1-20261107.010203-7");
+            }
+        }
+
+        @Test
+        void keepsTheNewestThreeNightliesAndEveryRelease(@TempDir Path temp) 
throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                publishVersion(fixture, "1.2.3");
+                assertEquals(0, install(fixture, home, "1.2.3").exit());
+                List<String> builds = List.of("9.9.0-20261101.010000-1", 
"9.9.0-20261102.010000-2",
+                        "9.9.0-20261103.010000-3", "9.9.0-20261104.010000-4", 
"9.9.0-20261105.010000-5");
+                for (String build : builds) {
+                    publishNightly(fixture, "9.9.0-SNAPSHOT", build, null);
+                    WebsiteInstallerFixture.Result r = install(fixture, home, 
"9.9.0-SNAPSHOT");
+                    assertEquals(0, r.exit(), r.stderr());
+                }
+
+                try (Stream<Path> listing = 
Files.list(home.resolve(".local/share/camel-cli/versions"))) {
+                    assertThat(listing.map(d -> 
d.getFileName().toString())).containsExactlyInAnyOrder(
+                            "1.2.3", "9.9.0-20261103.010000-3", 
"9.9.0-20261104.010000-4", "9.9.0-20261105.010000-5");
+                }
+                assertNightlyInstalled(home, "9.9.0-20261105.010000-5", 
"9.9.0-SNAPSHOT");
+            }
+        }
+
+        @Test
+        void releaseAfterNightlyReplacesTheSnapshotPin(@TempDir Path temp) 
throws Exception {
+            try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
+                Path home = Files.createDirectory(temp.resolve("home"));
+                publishNightly(fixture, "9.9.0-SNAPSHOT", NIGHTLY, null);
+                publishLatest(fixture, "1.2.3");
+                assertEquals(0, install(fixture, home, 
"9.9.0-SNAPSHOT").exit());
+
+                WebsiteInstallerFixture.Result r = install(fixture, home, 
null);
+
+                assertEquals(0, r.exit(), r.stderr());
+                assertVersionInstalled(home, "1.2.3");
+                assertThat(pinnedVersionFile(home)).doesNotExist();
+            }
+        }
+
         @Test
         void failedSelectorInstallPreservesExistingInstallAndPin(@TempDir Path 
temp) throws Exception {
             try (WebsiteInstallerFixture fixture = 
WebsiteInstallerFixture.start(temp.resolve("fixture"))) {
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java
index eb86670ea11a..cadfa3f53af8 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/WebsiteInstallerFixture.java
@@ -209,6 +209,10 @@ final class WebsiteInstallerFixture implements 
AutoCloseable {
         return baseUrl() + "/maven2/org/apache/camel/camel-launcher";
     }
 
+    String snapshotUrl() {
+        return baseUrl() + "/snapshots/org/apache/camel/camel-launcher";
+    }
+
     Path caCertificate() {
         return caCertPem;
     }
@@ -217,6 +221,7 @@ final class WebsiteInstallerFixture implements 
AutoCloseable {
         Map<String, String> env = new HashMap<>();
         env.put("CAMEL_INSTALL_MANIFEST_BASE_URL", baseUrl() + 
"/camel-cli/releases");
         env.put("CAMEL_INSTALL_MAVEN_BASE_URL", mavenUrl());
+        env.put("CAMEL_INSTALL_SNAPSHOT_BASE_URL", snapshotUrl());
         env.put("CAMEL_INSTALL_CA_CERT", caCertPem.toString());
         env.put("HOME", home.toString());
         env.put("USERPROFILE", home.toString());
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcherTest.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcherTest.java
index 7455d08c9a91..93fe6a6e4a6c 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcherTest.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/ManifestFetcherTest.java
@@ -197,4 +197,157 @@ class ManifestFetcherTest {
 
         assertThatThrownBy(() -> 
fetcher.fetchAlias(name)).isInstanceOf(IllegalArgumentException.class);
     }
+
+    // As the Apache snapshot repository publishes them (trimmed).
+    static final String ROOT_METADATA = """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <metadata>
+              <groupId>org.apache.camel</groupId>
+              <artifactId>camel-launcher</artifactId>
+              <versioning>
+                <latest>4.22.3-SNAPSHOT</latest>
+                <release></release>
+                <versions>
+                  <version>4.14.10-SNAPSHOT</version>
+                  <version>4.24.0-SNAPSHOT</version>
+                  <version>4.22.3-SNAPSHOT</version>
+                  <version>4.9.0</version>
+                </versions>
+              </versioning>
+            </metadata>
+            """;
+
+    static String versionMetadata(String snapshotVersion, String build) {
+        return """
+                <?xml version="1.0" encoding="UTF-8"?>
+                <metadata modelVersion="1.1.0">
+                  <groupId>org.apache.camel</groupId>
+                  <artifactId>camel-launcher</artifactId>
+                  <version>%1$s</version>
+                  <versioning>
+                    <snapshot>
+                      <timestamp>20261108.143653</timestamp>
+                      <buildNumber>24</buildNumber>
+                    </snapshot>
+                    <snapshotVersions>
+                      <snapshotVersion>
+                        <extension>pom</extension>
+                        <value>%2$s</value>
+                      </snapshotVersion>
+                      <snapshotVersion>
+                        <classifier>bin</classifier>
+                        <extension>zip</extension>
+                        <value>%2$s</value>
+                      </snapshotVersion>
+                      <snapshotVersion>
+                        <classifier>bin</classifier>
+                        <extension>tar.gz</extension>
+                        <value>%2$s</value>
+                      </snapshotVersion>
+                    </snapshotVersions>
+                  </versioning>
+                </metadata>
+                """.formatted(snapshotVersion, build);
+    }
+
+    @Test
+    void latestSnapshotVersionIsTheHighestNotTheLastDeployed() {
+        // <latest> names whatever deployed last, which can be a maintenance 
branch
+        
assertThat(ManifestFetcher.parseLatestSnapshotVersion(ROOT_METADATA.getBytes(StandardCharsets.UTF_8)))
+                .isEqualTo("4.24.0-SNAPSHOT");
+    }
+
+    @Test
+    void latestSnapshotVersionRequiresOne() {
+        String metadata = 
"<metadata><versioning><versions><version>4.9.0</version></versions></versioning></metadata>";
+
+        assertThatThrownBy(() -> 
ManifestFetcher.parseLatestSnapshotVersion(metadata.getBytes(StandardCharsets.UTF_8)))
+                .isInstanceOf(SelfUpdateException.class)
+                .hasMessageContaining("no X.Y.Z-SNAPSHOT version");
+    }
+
+    @Test
+    void newestBuildIsTheValueOfTheLauncherArchive() {
+        byte[] metadata = versionMetadata("4.24.0-SNAPSHOT", 
"4.24.0-20261108.143653-24").getBytes(StandardCharsets.UTF_8);
+
+        assertThat(ManifestFetcher.parseNewestBuild(metadata, 
"4.24.0-SNAPSHOT")).isEqualTo("4.24.0-20261108.143653-24");
+    }
+
+    // Nexus 2 serves this Maven 2 metadata to the JDK HTTP client: no 
<snapshotVersions>
+    @Test
+    void newestBuildFromMaven2Metadata() {
+        String metadata = """
+                <?xml version="1.0" encoding="UTF-8"?>
+                <metadata>
+                  <groupId>org.apache.camel</groupId>
+                  <artifactId>camel-launcher</artifactId>
+                  <version>4.24.0-SNAPSHOT</version>
+                  <versioning>
+                    <snapshot>
+                      <timestamp>20261108.143653</timestamp>
+                      <buildNumber>24</buildNumber>
+                    </snapshot>
+                    <lastUpdated>20261108143653</lastUpdated>
+                  </versioning>
+                </metadata>
+                """;
+
+        
assertThat(ManifestFetcher.parseNewestBuild(metadata.getBytes(StandardCharsets.UTF_8),
 "4.24.0-SNAPSHOT"))
+                .isEqualTo("4.24.0-20261108.143653-24");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "20261108.143653</timestamp><x>", "2026/../../x", 
"" })
+    void rejectsAnInvalidMaven2Snapshot(String timestamp) {
+        String metadata = "<metadata><versioning><snapshot><timestamp>" + 
timestamp
+                          + 
"</timestamp><buildNumber>24</buildNumber></snapshot></versioning></metadata>";
+
+        assertThatThrownBy(() -> 
ManifestFetcher.parseNewestBuild(metadata.getBytes(StandardCharsets.UTF_8),
+                "4.24.0-SNAPSHOT"))
+                .isInstanceOf(SelfUpdateException.class);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "4.24.0-20261108.143653-24/../../x", 
"4.22.3-20261108.143653-24", "4.24.0-SNAPSHOT", "" })
+    void rejectsAnInvalidOrForeignBuild(String build) {
+        byte[] metadata = versionMetadata("4.24.0-SNAPSHOT", 
build).getBytes(StandardCharsets.UTF_8);
+
+        assertThatThrownBy(() -> ManifestFetcher.parseNewestBuild(metadata, 
"4.24.0-SNAPSHOT"))
+                .isInstanceOf(SelfUpdateException.class)
+                .hasMessageContaining("invalid nightly build of 
4.24.0-SNAPSHOT");
+    }
+
+    @Test
+    void rejectsMetadataWithADoctype() {
+        String metadata = "<?xml version=\"1.0\"?><!DOCTYPE metadata [<!ENTITY 
x SYSTEM \"file:///etc/passwd\">]>"
+                          + 
"<metadata><versioning><versions><version>&x;</version></versions></versioning></metadata>";
+
+        assertThatThrownBy(() -> 
ManifestFetcher.parseLatestSnapshotVersion(metadata.getBytes(StandardCharsets.UTF_8)))
+                .isInstanceOf(SelfUpdateException.class)
+                .hasMessageContaining("the snapshot metadata is not valid");
+    }
+
+    @Test
+    void fetchesTheNewestBuildOverHttp() throws Exception {
+        HttpServer server = serve(Map.of(
+                "/snapshots/maven-metadata.xml", ROOT_METADATA,
+                "/snapshots/4.24.0-SNAPSHOT/maven-metadata.xml",
+                versionMetadata("4.24.0-SNAPSHOT", 
"4.24.0-20261108.143653-24")));
+        try {
+            String base = "http://127.0.0.1:"; + server.getAddress().getPort();
+            ManifestFetcher fetcher = new ManifestFetcher(base + "/releases", 
base + "/maven2", base + "/snapshots");
+
+            String snapshotVersion = fetcher.fetchLatestSnapshotVersion();
+
+            assertThat(snapshotVersion).isEqualTo("4.24.0-SNAPSHOT");
+            
assertThat(fetcher.fetchNewestBuild(snapshotVersion)).isEqualTo("4.24.0-20261108.143653-24");
+            assertThatThrownBy(() -> 
fetcher.fetchNewestBuild("4.22.3-SNAPSHOT"))
+                    .isInstanceOf(SelfUpdateException.class)
+                    .hasMessageContaining("no nightly builds of 
4.22.3-SNAPSHOT at");
+            assertThatThrownBy(() -> fetcher.fetchNewestBuild("../x"))
+                    .isInstanceOf(IllegalArgumentException.class);
+        } finally {
+            server.stop(0);
+        }
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/NightlyTest.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/NightlyTest.java
new file mode 100644
index 000000000000..7858c1929011
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/NightlyTest.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.launcher.selfupdate;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class NightlyTest {
+
+    @Test
+    void classifiesNightlySelectors() {
+        assertThat(Nightly.isLatest("SNAPSHOT")).isTrue();
+        assertThat(Nightly.isLatest("snapshot")).isTrue();
+        assertThat(Nightly.isSnapshotVersion("4.24.0-SNAPSHOT")).isTrue();
+        assertThat(Nightly.isBuild("4.24.0-20261108.143653-24")).isTrue();
+        assertThat(Nightly.isNightly("4.24.0")).isFalse();
+        assertThat(Nightly.isNightly("LTS")).isFalse();
+        assertThat(Nightly.normalize("snapshot")).isEqualTo("SNAPSHOT");
+        
assertThat(Nightly.normalize("4.24.0-SNAPSHOT")).isEqualTo("4.24.0-SNAPSHOT");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "4.24.0-20261108.143653", "4.24.0-20261108-24", 
"4.24.0-2026110.143653-24", "4.24.0-20261108.143653-",
+            "4.24.0-20261108.143653-24-1", "4.24-20261108.143653-24", 
"4.24.0-20261108.143653-24/..",
+            "../4.24.0-20261108.143653-24", 
"4.24.0-20261108.143653-1234567890" })
+    void rejectsMalformedBuilds(String value) {
+        assertThat(Nightly.isBuild(value)).isFalse();
+        assertThat(Nightly.isNightly(value)).isFalse();
+    }
+
+    @Test
+    void snapshotVersionOfBuild() {
+        
assertThat(Nightly.snapshotVersionOf("4.22.3-20261107.143456-15")).isEqualTo("4.22.3-SNAPSHOT");
+        assertThatThrownBy(() -> 
Nightly.snapshotVersionOf("4.22.3")).isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    void comparesBuildsByTimestampThenBuildNumber() {
+        assertThat(Nightly.compare("4.24.0-20261109.010000-25", 
"4.24.0-20261108.143653-24")).isPositive();
+        assertThat(Nightly.compare("4.24.0-20261108.143653-24", 
"4.24.0-20261108.143653-24")).isZero();
+        // the build number is compared as a number, not as text
+        assertThat(Nightly.compare("4.24.0-20261108.143653-10", 
"4.24.0-20261108.143653-9")).isPositive();
+    }
+
+    @Test
+    void runningBuildIsTheVersionDirectoryOfTheLauncher() {
+        Path versions = Path.of("/home/user/.local/share/camel-cli/versions");
+
+        assertThat(Nightly.runningBuild(
+                
versions.resolve("4.24.0-20261108.143653-24/lib/camel-launcher-4.24.0-SNAPSHOT.jar"),
 versions))
+                .contains("4.24.0-20261108.143653-24");
+        
assertThat(Nightly.runningBuild(versions.resolve("4.23.0/lib/camel-launcher-4.23.0.jar"),
 versions)).isEmpty();
+        
assertThat(Nightly.runningBuild(Path.of("/opt/camel/lib/camel-launcher-4.24.0-SNAPSHOT.jar"),
 versions)).isEmpty();
+        assertThat(Nightly.runningBuild(null, versions)).isEmpty();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/PinTest.java
 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/PinTest.java
index 2eb3533cb3f4..c5d484832ede 100644
--- 
a/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/PinTest.java
+++ 
b/dsl/camel-jbang/camel-launcher/src/test/java/org/apache/camel/dsl/jbang/launcher/selfupdate/PinTest.java
@@ -68,7 +68,10 @@ class PinTest {
     }
 
     @ParameterizedTest
-    @ValueSource(strings = { "", "4", "4.", "4.22.", "4..22", "4.22.2.1", 
"4.22.2-SNAPSHOT", "latest", "LTS2", "v4.22" })
+    @ValueSource(strings = {
+            "", "4", "4.", "4.22.", "4..22", "4.22.2.1", "4.22.2-snapshot", 
"4.22.2-SNAPSHOT1", "SNAPSHOTS",
+            "4.22.2-20261108.143653", "4.22.2-20261108.1436-24", 
"4.22.2-20261108.143653-24/..", "latest", "LTS2",
+            "v4.22" })
     void treatsAnythingElseAsInvalidAndNeverMoves(String raw) {
         Pin pin = Pin.parse(raw);
 
@@ -76,4 +79,44 @@ class PinTest {
         assertThat(pin.tracksAlias()).isFalse();
         assertThat(pin.accepts("4.22.2")).isFalse();
     }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "SNAPSHOT", "snapshot", " Snapshot\r\n" })
+    void parsesNightliesOfMainPin(String raw) {
+        Pin pin = Pin.parse(raw);
+
+        assertThat(pin.kind()).isEqualTo(Pin.Kind.SNAPSHOT);
+        assertThat(pin.value()).isEqualTo("SNAPSHOT");
+        assertThat(pin.followsNightlies()).isTrue();
+        assertThat(pin.tracksAlias()).isFalse();
+        assertThat(pin.accepts("4.24.0-20261108.143653-24")).isTrue();
+        assertThat(pin.accepts("4.22.3-20261107.143456-15")).isTrue();
+        assertThat(pin.accepts("4.24.0")).isFalse();
+        assertThat(pin.describe()).isEqualTo("the nightly builds of main");
+        
assertThatThrownBy(pin::aliasName).isInstanceOf(IllegalStateException.class);
+    }
+
+    @Test
+    void parsesNightliesOfSnapshotVersionPin() {
+        Pin pin = Pin.parse("4.22.3-SNAPSHOT\n");
+
+        assertThat(pin.kind()).isEqualTo(Pin.Kind.SNAPSHOT);
+        assertThat(pin.followsNightlies()).isTrue();
+        assertThat(pin.accepts("4.22.3-20261107.143456-15")).isTrue();
+        assertThat(pin.accepts("4.24.0-20261108.143653-24")).isFalse();
+        assertThat(pin.accepts("4.22.3")).isFalse();
+        assertThat(pin.describe()).isEqualTo("the nightly builds of 
4.22.3-SNAPSHOT");
+    }
+
+    @Test
+    void parsesOneNightlyBuildPinWhichNeverMoves() {
+        Pin pin = Pin.parse("4.24.0-20261108.143653-24");
+
+        assertThat(pin.kind()).isEqualTo(Pin.Kind.NIGHTLY);
+        assertThat(pin.followsNightlies()).isFalse();
+        assertThat(pin.tracksAlias()).isFalse();
+        assertThat(pin.accepts("4.24.0-20261108.143653-24")).isTrue();
+        assertThat(pin.accepts("4.24.0-20261109.143653-25")).isFalse();
+        assertThat(pin.describe()).isEqualTo("version 
4.24.0-20261108.143653-24");
+    }
 }

Reply via email to