From 49e4bce51eb9cff48eab645f99b432cb4cec2abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Werner=20Fouch=C3=A9?= Date: Sat, 21 Feb 2026 12:47:22 +0200 Subject: [PATCH 01/15] chore(deps): upgrade default Kotlin version to 2.3.10 (#2399) --- src/main/java/dev/jbang/net/KotlinManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/dev/jbang/net/KotlinManager.java b/src/main/java/dev/jbang/net/KotlinManager.java index 3e3f520cb1..f9410669f0 100644 --- a/src/main/java/dev/jbang/net/KotlinManager.java +++ b/src/main/java/dev/jbang/net/KotlinManager.java @@ -15,7 +15,7 @@ public class KotlinManager { private static final String KOTLIN_DOWNLOAD_URL = "https://github.com/JetBrains/kotlin/releases/download/v%s/kotlin-compiler-%s.zip"; - public static final String DEFAULT_KOTLIN_VERSION = "2.1.21"; + public static final String DEFAULT_KOTLIN_VERSION = "2.3.10"; public static String resolveInKotlinHome(String cmd, String requestedVersion) { Path kotlinHome = getKotlin(requestedVersion); From a9c91269413711f99e9d4d688cd57fce4efe7b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Werner=20Fouch=C3=A9?= Date: Sat, 21 Feb 2026 12:47:59 +0200 Subject: [PATCH 02/15] chore(deps): upgrade gson to 2.13.2 (#2395) --- build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index e256ccb3d6..0cffac3a2f 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,6 @@ plugins { id 'maven-publish' id 'io.qameta.allure-report' version '2.12.0' id "io.qameta.allure-adapter-base" version "2.12.0" - } def allureVersion = '2.29.1' @@ -160,7 +159,7 @@ dependencies { annotationProcessor 'info.picocli:picocli-codegen:4.7.7' implementation 'io.quarkus.qute:qute-core:1.13.7.Final' implementation 'org.codehaus.plexus:plexus-java:1.2.0' - implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.google.code.gson:gson:2.13.2' implementation 'org.jsoup:jsoup:1.17.1' implementation 'org.codejive:java-properties:0.0.7' From 74f9cf88b26ec30deff054c675d6c7101a67665a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Werner=20Fouch=C3=A9?= Date: Sat, 21 Feb 2026 12:48:30 +0200 Subject: [PATCH 03/15] chore(deps): upgrade default Groovy version to 4.0.30 (#2398) --- src/main/java/dev/jbang/net/GroovyManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/dev/jbang/net/GroovyManager.java b/src/main/java/dev/jbang/net/GroovyManager.java index 48550527a0..f40d989d9e 100644 --- a/src/main/java/dev/jbang/net/GroovyManager.java +++ b/src/main/java/dev/jbang/net/GroovyManager.java @@ -16,7 +16,7 @@ import dev.jbang.util.Util; public class GroovyManager { - public static final String DEFAULT_GROOVY_VERSION = "4.0.27"; + public static final String DEFAULT_GROOVY_VERSION = "4.0.30"; public static String resolveInGroovyHome(String cmd, String requestedVersion) { Path groovyHome = getGroovy(requestedVersion); From 66e7bfc3abb857f832f87205df60b1bea9bde272 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Sat, 21 Feb 2026 12:03:41 +0100 Subject: [PATCH 04/15] app catalog docs (#2404) Co-authored-by: Max Rydahl Andersen Co-authored-by: Cursor Agent --- docs/modules/ROOT/pages/alias_catalogs.adoc | 2 + docs/modules/ROOT/pages/app-installation.adoc | 1 + docs/modules/ROOT/pages/index.adoc | 1 + .../ROOT/pages/publishing-app-catalogs.adoc | 173 ++++++++++++++++++ docs/modules/ROOT/partials/nav.adoc | 1 + 5 files changed, 178 insertions(+) create mode 100644 docs/modules/ROOT/pages/publishing-app-catalogs.adoc diff --git a/docs/modules/ROOT/pages/alias_catalogs.adoc b/docs/modules/ROOT/pages/alias_catalogs.adoc index b9494a2ae8..086f456d84 100644 --- a/docs/modules/ROOT/pages/alias_catalogs.adoc +++ b/docs/modules/ROOT/pages/alias_catalogs.adoc @@ -15,6 +15,8 @@ endif::[] To avoid remembering long paths and to enable easy launch of jbang scripts there is an `alias` command to setup and manage aliases to actual scripts. +NOTE: If your goal is to publish app commands for others, start with xref:publishing-app-catalogs.adoc[Publishing App Catalogs]. It is a shorter, app-focused guide for enabling commands like `jbang @`. + [source,bash] ---- jbang alias add --name hello https://github.com/jbangdev/jbang-examples/blob/HEAD/examples/helloworld.java diff --git a/docs/modules/ROOT/pages/app-installation.adoc b/docs/modules/ROOT/pages/app-installation.adoc index 73ca175152..92b5718b78 100644 --- a/docs/modules/ROOT/pages/app-installation.adoc +++ b/docs/modules/ROOT/pages/app-installation.adoc @@ -311,6 +311,7 @@ ls -la ~/.jbang/bin/ == What's Next? +- **Publish your apps via catalogs** → xref:publishing-app-catalogs.adoc[Publishing App Catalogs] - **Share your tools** → xref:alias_catalogs.adoc[Aliases & Catalogs] - **Build complex apps** → xref:organizing.adoc[Organizing Code] - **Create native binaries** → xref:native-images.adoc[Native Images] diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index f376b37277..6ebde12a54 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -80,6 +80,7 @@ Explore powerful JBang capabilities: * xref:running.adoc[Running & Execution] - Advanced execution options * xref:editing.adoc[IDE Integration] - Edit with full IDE support * xref:exporting.adoc[Exporting Projects] - Convert to traditional projects +* xref:publishing-app-catalogs.adoc[Publishing App Catalogs] - Let users run your tools as `jbang @` * xref:alias_catalogs.adoc[Aliases & Catalogs] - Share and manage scripts === ⚙️ Configuration & Tools diff --git a/docs/modules/ROOT/pages/publishing-app-catalogs.adoc b/docs/modules/ROOT/pages/publishing-app-catalogs.adoc new file mode 100644 index 0000000000..33a2372f4b --- /dev/null +++ b/docs/modules/ROOT/pages/publishing-app-catalogs.adoc @@ -0,0 +1,173 @@ += Publish Apps with a JBang Catalog +:idprefix: +:idseparator: - +ifndef::env-github[] +:icons: font +endif::[] +ifdef::env-github[] +:caution-caption: :fire: +:important-caption: :exclamation: +:note-caption: :paperclip: +:tip-caption: :bulb: +:warning-caption: :warning: +endif::[] + +If you follow this guide, users can run your tools like this: + +[source,bash] +---- +jbang @ + +jbang mytool@your-org +jbang mytool-lts@your-org +---- + +Once you have such a name for you app, users can run it via jbang - but can also install it as a command on their system, using `jbang app install @`. + +[source,bash] +---- +jbang app install mytool@your-org +mytool --help +---- + +Real-world examples of commands you can run: + +[source,bash] +--- +jbang minecraft-server@microsoft +jbang camel@redhat-camel +jbang quarkus@quarkusio +jbang arthas@alibaba +---- + +That is the main value of a catalog: your users get a short, stable command name, and JBang handles how to run the app behind it. + + +For most teams publishing apps, the best starting point is aliases that target released Maven artifacts (GAVs) or JARs. JBang scripts are also supported, but +not a requirement. + +== Why publish a catalog? + +A catalog gives you: + +- A simple command UX (`@`) for your users +- One place to curate and describe your app commands +- Flexibility to point commands at Maven coordinates, JARs, or scripts without changing user-facing command names + +In other words, you publish app names; JBang handles execution details. + +== What should aliases point to? + +For app catalogs, this is the recommended order: + +1. **Maven artifact (GAV)** - best default for versioned releases +2. **JAR** - great for private/internal distribution or direct downloads, i.e. github releases +3. **Script** - fully supported, but usually a secondary publishing path for app catalogs + +JBang treats all of these as runnable app targets behind the same alias UX. + +== Where should the catalog live? + +[cols="1,2,2,2", options="header"] +|=== +|Option |Where to put it |How users run it |Best for + +|**Org-level catalog** (recommended) +|Create a repository named `jbang-catalog` under your org/user +|`jbang @` +|Multiple tools and a stable, shorter, org-wide command namespace + +|**Repo-level catalog** +|Add `jbang-catalog.json` to an existing repository +|`jbang @/` +|A single product/repository that owns its own commands, and no org-wide command namespace +|=== + +== Option A: org-level catalog repository (recommended) + +[TIP] +==== +Run `jbang init -t jbang-catalog jbang-catalog` to quickly create a catalog repository with renovatebot and GitHub action setup to keep dependencies up to date. Then you can skip to step #3 +==== + +=== 1) Create the catalog repository + +Create `https://github.com//jbang-catalog` (or equivalent on GitLab/Bitbucket). + +=== 2) Add `jbang-catalog.json` + +Use a minimal app-focused catalog like this: + +[source,json] +---- +{ + "aliases": { + "mytool": { + "script-ref": "com.yourorg:mytool-cli:1.3.0", + "description": "Run MyTool CLI from Maven" + }, + "mytool-lts": { + "script-ref": "https://downloads.yourorg.com/mytool/mytool-cli-1.2.5.jar", + "description": "Run MyTool LTS jar" + } + } +} +---- + +`script-ref` is the target field name and can point to a GAV, JAR, or script. For app catalogs, prefer GAV or JAR targets first. + +=== 3) Commit and push + +Publish the file in the default branch. + +=== 4) Validate the user command + +[source,bash] +---- +jbang mytool@your-org +---- + +If needed while iterating on changes, test with `--fresh` to bypass caches. + +== Option B: catalog file in an existing repository + +If you do not want a dedicated `jbang-catalog` repository, add `jbang-catalog.json` to your existing repository. + +With a file in the repo root, users can run: + +[source,bash] +---- +jbang mytool@your-org/your-repo +---- + +This is often a good fit when command aliases are tightly coupled to one project. + +== Managing entries quickly + +Instead of editing JSON by hand, you can add aliases via CLI: + +[source,bash] +---- +# Preferred: alias pointing to Maven artifact (GAV) +jbang alias add --file jbang-catalog.json --name mytool \ + --description "Run MyTool CLI from Maven" \ + com.yourorg:mytool-cli:1.3.0 + +# Preferred: alias pointing to JAR +jbang alias add --file jbang-catalog.json --name mytool-lts \ + --description "Run MyTool LTS jar" \ + https://downloads.yourorg.com/mytool/mytool-cli-1.2.5.jar + +# Also supported: alias pointing to script +jbang alias add --file jbang-catalog.json --name mytool-dev \ + --description "Run MyTool development script" \ + mytool.java +---- + +See the full reference for xref:jbang:cli:jbang-alias.adoc[`jbang alias`] and xref:jbang:cli:jbang-catalog.adoc[`jbang catalog`]. + +== Beyond app commands (optional) + +Catalogs can also include templates and other entries (for example, jshell-oriented helpers), but most teams should start by publishing clear app aliases first. + +For advanced catalog behavior and implicit catalog syntax details, see xref:alias_catalogs.adoc[Aliases & Catalogs] and xref:templates.adoc[Templates]. diff --git a/docs/modules/ROOT/partials/nav.adoc b/docs/modules/ROOT/partials/nav.adoc index 702006c594..719983b6e1 100644 --- a/docs/modules/ROOT/partials/nav.adoc +++ b/docs/modules/ROOT/partials/nav.adoc @@ -23,6 +23,7 @@ * Distribution & Deployment ** xref:jbang:ROOT:exporting.adoc[Exporting Projects] ** xref:jbang:ROOT:app-installation.adoc[Installing as Apps] +** xref:jbang:ROOT:publishing-app-catalogs.adoc[Publishing App Catalogs] ** xref:jbang:ROOT:alias_catalogs.adoc[Aliases & Catalogs] ** xref:jbang:ROOT:integration.adoc[Build Integration] From 74df5cc134019a24640d5d56fcf5b6d5b56cad92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Werner=20Fouch=C3=A9?= Date: Sat, 21 Feb 2026 13:51:28 +0200 Subject: [PATCH 05/15] fix: replaced plugin gradle.enterprise with gradle.develocity (#2402) --- settings.gradle | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/settings.gradle b/settings.gradle index d4d4793250..61bac988ee 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,24 +1,12 @@ -pluginManagement { - plugins { - id "com.gradle.enterprise" version "3.7.2" - } -} - plugins { - id "com.gradle.enterprise" + id "com.gradle.develocity" version "4.3.2" id "org.gradle.toolchains.foojay-resolver-convention" version "0.10.0" } - - -// Configuration of com.gradle.enterprise (build scan) plugin -gradleEnterprise { +develocity { buildScan { - // Accept the license agreement for com.gradle.build-scan plugin - termsOfServiceUrl = 'https://gradle.com/terms-of-service' - termsOfServiceAgree = 'yes' - - // Always publish scan (no more need for --scan option) - //publishAlways() + termsOfUseUrl.set("https://gradle.com/help/legal-terms-of-use") + termsOfUseAgree.set("yes") + publishing.onlyIf { false } } } From bfb8feee3127912e502c9223a139550af99ea93f Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 06/15] feat(lock): phase 1 checksum verify and basic lockfile enforcement Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- src/main/java/dev/jbang/cli/Run.java | 116 ++++++++++++++++++ .../java/dev/jbang/util/LockFileUtil.java | 41 +++++++ .../java/dev/jbang/cli/TestRunChecksum.java | 40 ++++++ 3 files changed, 197 insertions(+) create mode 100644 src/main/java/dev/jbang/util/LockFileUtil.java create mode 100644 src/test/java/dev/jbang/cli/TestRunChecksum.java diff --git a/src/main/java/dev/jbang/cli/Run.java b/src/main/java/dev/jbang/cli/Run.java index 75a2f768b7..b9a177f318 100644 --- a/src/main/java/dev/jbang/cli/Run.java +++ b/src/main/java/dev/jbang/cli/Run.java @@ -1,6 +1,10 @@ package dev.jbang.cli; import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -13,6 +17,7 @@ import dev.jbang.source.Project; import dev.jbang.source.ProjectBuilder; import dev.jbang.source.Source; +import dev.jbang.util.LockFileUtil; import dev.jbang.util.Util; import picocli.CommandLine; @@ -27,6 +32,18 @@ public class Run extends BaseBuildCommand { "--code" }, arity = "0..1", description = "Run the given string as code", preprocessor = StrictParameterPreprocessor.class) public Optional literalScript; + @CommandLine.Option(names = { "--verify" }, description = "Verify script content with digest, e.g. sha256:abc123") + String verifyDigest; + + @CommandLine.Option(names = { "--locked" }, description = "Require matching digest from lock file") + boolean locked; + + @CommandLine.Option(names = { "--lock-file" }, description = "Path to lock file (default: .jbang.lock)") + Path lockFile; + + @CommandLine.Option(names = { "--lock-write" }, description = "Write/update digest entry in lock file") + boolean lockWrite; + @CommandLine.Parameters(index = "1..*", arity = "0..*", description = "Parameters to pass on to the script") public List userParams = new ArrayList<>(); @@ -54,6 +71,10 @@ public Integer doCall() throws IOException { userParams = handleRemoteFiles(userParams); String scriptOrFile = scriptMixin.scriptOrFile; + RefWithChecksum refWithChecksum = scriptOrFile != null ? splitRefAndChecksum(scriptOrFile) : null; + if (refWithChecksum != null) { + scriptOrFile = refWithChecksum.ref; + } ProjectBuilder pb = createProjectBuilderForRun(); @@ -78,6 +99,33 @@ public Integer doCall() throws IOException { } } + if (!literalScript.isPresent() && scriptOrFile != null) { + Path effectiveLockFile = lockFile != null ? lockFile : Util.getCwd().resolve(".jbang.lock"); + String actualDigest = digestResource(prj, "sha256"); + String lockDigest = null; + if (locked || lockWrite) { + lockDigest = LockFileUtil.readDigest(effectiveLockFile, scriptOrFile); + } + + if (verifyDigest != null) { + verifyDigestSpec(actualDigest, verifyDigest, "--verify"); + } + if (refWithChecksum != null && refWithChecksum.checksum != null) { + verifyDigestSpec(actualDigest, refWithChecksum.checksum, "reference checksum"); + } + if (locked) { + if (lockDigest == null) { + throw new ExitException(EXIT_INVALID_INPUT, + "No lock entry for reference: " + scriptOrFile + " in " + effectiveLockFile, null); + } + verifyDigestSpec(actualDigest, lockDigest, "lockfile"); + } + if (lockWrite) { + LockFileUtil.writeDigest(effectiveLockFile, scriptOrFile, actualDigest); + info("Updated lock entry for " + scriptOrFile + " in " + effectiveLockFile); + } + } + if (Boolean.TRUE.equals(nativeMixin.nativeImage) && (scriptMixin.forceType == Source.Type.jshell || prj.isJShell())) { warn(".jsh cannot be used with --native thus ignoring --native."); @@ -161,6 +209,74 @@ private static Map handleRemoteFiles(Map slots) return result; } + static final class RefWithChecksum { + final String ref; + final String checksum; + + RefWithChecksum(String ref, String checksum) { + this.ref = ref; + this.checksum = checksum; + } + } + + static RefWithChecksum splitRefAndChecksum(String ref) { + int idx = ref.lastIndexOf('#'); + if (idx < 0 || idx == ref.length() - 1) { + return new RefWithChecksum(ref, null); + } + String digest = ref.substring(idx + 1); + if (!digest.contains(":")) { + return new RefWithChecksum(ref, null); + } + return new RefWithChecksum(ref.substring(0, idx), digest); + } + + private static String digestResource(Project prj, String algorithm) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance(algorithm.toUpperCase(Locale.ROOT)); + try (InputStream in = prj.getResourceRef().getInputStream()) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) >= 0) { + md.update(buffer, 0, read); + } + } + return algorithm.toLowerCase(Locale.ROOT) + ":" + toHex(md.digest()); + } catch (NoSuchAlgorithmException e) { + throw new ExitException(EXIT_INVALID_INPUT, "Unsupported digest algorithm: " + algorithm, e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + static void verifyDigestSpec(String actualDigest, String expectedDigest, String source) { + String[] actualParts = actualDigest.split(":", 2); + String[] expectedParts = expectedDigest.split(":", 2); + if (expectedParts.length != 2) { + throw new ExitException(EXIT_INVALID_INPUT, "Invalid digest format from " + source + ": " + expectedDigest, null); + } + if (!actualParts[0].equalsIgnoreCase(expectedParts[0])) { + throw new ExitException(EXIT_INVALID_INPUT, + "Digest algorithm mismatch in " + source + ": expected " + expectedParts[0] + ", got " + actualParts[0], null); + } + String expectedHex = expectedParts[1].toLowerCase(Locale.ROOT); + String actualHex = actualParts[1].toLowerCase(Locale.ROOT); + if (expectedHex.length() < 12) { + throw new ExitException(EXIT_INVALID_INPUT, + "Digest prefix too short in " + source + ". Use at least 12 hex characters.", null); + } + if (!actualHex.startsWith(expectedHex)) { + throw new ExitException(EXIT_INVALID_INPUT, + "Digest mismatch in " + source + ": expected " + expectedDigest + ", got " + actualDigest, null); + } + } + /** * Helper class to peek ahead at `--debug` to pickup --debug=5000, --debug 5000, * --debug *:5000 as debug parameters but not --debug somefile.java diff --git a/src/main/java/dev/jbang/util/LockFileUtil.java b/src/main/java/dev/jbang/util/LockFileUtil.java new file mode 100644 index 0000000000..9c0d5f89bf --- /dev/null +++ b/src/main/java/dev/jbang/util/LockFileUtil.java @@ -0,0 +1,41 @@ +package dev.jbang.util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +public final class LockFileUtil { + + private LockFileUtil() { + } + + public static String readDigest(Path lockFile, String ref) throws IOException { + if (!Files.exists(lockFile)) { + return null; + } + Properties p = new Properties(); + try (InputStream in = Files.newInputStream(lockFile)) { + p.load(in); + } + return p.getProperty(ref); + } + + public static void writeDigest(Path lockFile, String ref, String digest) throws IOException { + Properties p = new Properties(); + if (Files.exists(lockFile)) { + try (InputStream in = Files.newInputStream(lockFile)) { + p.load(in); + } + } + p.setProperty(ref, digest); + if (lockFile.getParent() != null) { + Files.createDirectories(lockFile.getParent()); + } + try (OutputStream out = Files.newOutputStream(lockFile)) { + p.store(out, "JBang lockfile v1"); + } + } +} diff --git a/src/test/java/dev/jbang/cli/TestRunChecksum.java b/src/test/java/dev/jbang/cli/TestRunChecksum.java new file mode 100644 index 0000000000..f8d0c197db --- /dev/null +++ b/src/test/java/dev/jbang/cli/TestRunChecksum.java @@ -0,0 +1,40 @@ +package dev.jbang.cli; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +public class TestRunChecksum { + + @Test + void splitRefAndChecksumParsesDigestSuffix() { + Run.RefWithChecksum ref = Run.splitRefAndChecksum("alias@catalog#sha256:abcdef123456"); + assertEquals("alias@catalog", ref.ref); + assertEquals("sha256:abcdef123456", ref.checksum); + } + + @Test + void splitRefAndChecksumLeavesUrlsWithoutDigestUntouched() { + Run.RefWithChecksum ref = Run.splitRefAndChecksum("https://example.org/foo#frag"); + assertEquals("https://example.org/foo#frag", ref.ref); + assertEquals(null, ref.checksum); + } + + @Test + void verifyDigestSpecAcceptsPrefix() { + Run.verifyDigestSpec("sha256:abcdef1234567890", "sha256:abcdef123456", "test"); + } + + @Test + void verifyDigestSpecRejectsShortPrefix() { + assertThrows(ExitException.class, + () -> Run.verifyDigestSpec("sha256:abcdef1234567890", "sha256:abcd", "test")); + } + + @Test + void verifyDigestSpecRejectsMismatch() { + assertThrows(ExitException.class, + () -> Run.verifyDigestSpec("sha256:abcdef1234567890", "sha256:bbbbbbbbbbbb", "test")); + } +} From a4c58f8cba7d5460994d9d9240c3adbf3e653b83 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 07/15] feat(lock): add lock command and source manifest entries Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- src/main/java/dev/jbang/cli/JBang.java | 2 +- src/main/java/dev/jbang/cli/Lock.java | 77 +++++++++++++++++++ .../java/dev/jbang/util/LockFileUtil.java | 25 ++++++ .../java/dev/jbang/util/TestLockFileUtil.java | 20 +++++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/jbang/cli/Lock.java create mode 100644 src/test/java/dev/jbang/util/TestLockFileUtil.java diff --git a/src/main/java/dev/jbang/cli/JBang.java b/src/main/java/dev/jbang/cli/JBang.java index 13f5c0b504..d86f87e4a6 100644 --- a/src/main/java/dev/jbang/cli/JBang.java +++ b/src/main/java/dev/jbang/cli/JBang.java @@ -60,7 +60,7 @@ "" }, versionProvider = VersionProvider.class, subcommands = { Run.class, Build.class, Edit.class, Init.class, Alias.class, Template.class, Catalog.class, Trust.class, Cache.class, Completion.class, Jdk.class, Version.class, Wrapper.class, Info.class, App.class, - Export.class, Config.class, Deps.class }) + Export.class, Config.class, Deps.class, Lock.class }) public class JBang extends BaseCommand { @CommandLine.Option(names = { "-V", diff --git a/src/main/java/dev/jbang/cli/Lock.java b/src/main/java/dev/jbang/cli/Lock.java new file mode 100644 index 0000000000..d8a8f11ec5 --- /dev/null +++ b/src/main/java/dev/jbang/cli/Lock.java @@ -0,0 +1,77 @@ +package dev.jbang.cli; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import dev.jbang.source.Project; +import dev.jbang.source.ProjectBuilder; +import dev.jbang.util.LockFileUtil; +import dev.jbang.util.Util; +import picocli.CommandLine; + +@CommandLine.Command(name = "lock", description = "Generate or refresh lock entries for script references") +public class Lock extends BaseBuildCommand { + + @CommandLine.Option(names = { "--lock-file" }, description = "Path to lock file (default: .jbang.lock)") + Path lockFile; + + @CommandLine.Option(names = { "--algorithm" }, description = "Digest algorithm (default: sha256)") + String algorithm = "sha256"; + + @Override + public Integer doCall() throws IOException { + scriptMixin.validate(true); + String ref = scriptMixin.scriptOrFile; + Run.RefWithChecksum parsed = Run.splitRefAndChecksum(ref); + ref = parsed.ref; + + ProjectBuilder pb = createBaseProjectBuilder(); + Project prj = pb.build(ref); + + String digest = digestResource(prj, algorithm); + Path effectiveLockFile = lockFile != null ? lockFile : Util.getCwd().resolve(".jbang.lock"); + List sources = prj.getMainSourceSet().getSources().stream() + .map(s -> relativizeSafe(s.getOriginalResource())) + .collect(Collectors.toList()); + LockFileUtil.write(effectiveLockFile, ref, digest, sources); + info("Locked " + ref + " => " + digest + " in " + effectiveLockFile); + return EXIT_OK; + } + + private static String relativizeSafe(String val) { + if (val == null) { + return ""; + } + return val; + } + + private static String digestResource(Project prj, String algorithm) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance(algorithm.toUpperCase(Locale.ROOT)); + try (InputStream in = prj.getResourceRef().getInputStream()) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) >= 0) { + md.update(buffer, 0, read); + } + } + return algorithm.toLowerCase(Locale.ROOT) + ":" + toHex(md.digest()); + } catch (NoSuchAlgorithmException e) { + throw new ExitException(EXIT_INVALID_INPUT, "Unsupported digest algorithm: " + algorithm, e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/src/main/java/dev/jbang/util/LockFileUtil.java b/src/main/java/dev/jbang/util/LockFileUtil.java index 9c0d5f89bf..d49307ed7d 100644 --- a/src/main/java/dev/jbang/util/LockFileUtil.java +++ b/src/main/java/dev/jbang/util/LockFileUtil.java @@ -5,7 +5,10 @@ import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; import java.util.Properties; +import java.util.stream.Collectors; public final class LockFileUtil { @@ -23,7 +26,26 @@ public static String readDigest(Path lockFile, String ref) throws IOException { return p.getProperty(ref); } + public static List readSources(Path lockFile, String ref) throws IOException { + if (!Files.exists(lockFile)) { + return java.util.Collections.emptyList(); + } + Properties p = new Properties(); + try (InputStream in = Files.newInputStream(lockFile)) { + p.load(in); + } + String val = p.getProperty(ref + ".sources"); + if (val == null || val.trim().isEmpty()) { + return java.util.Collections.emptyList(); + } + return Arrays.stream(val.split(",")).filter(s -> !s.trim().isEmpty()).collect(Collectors.toList()); + } + public static void writeDigest(Path lockFile, String ref, String digest) throws IOException { + write(lockFile, ref, digest, null); + } + + public static void write(Path lockFile, String ref, String digest, List sources) throws IOException { Properties p = new Properties(); if (Files.exists(lockFile)) { try (InputStream in = Files.newInputStream(lockFile)) { @@ -31,6 +53,9 @@ public static void writeDigest(Path lockFile, String ref, String digest) throws } } p.setProperty(ref, digest); + if (sources != null && !sources.isEmpty()) { + p.setProperty(ref + ".sources", String.join(",", sources)); + } if (lockFile.getParent() != null) { Files.createDirectories(lockFile.getParent()); } diff --git a/src/test/java/dev/jbang/util/TestLockFileUtil.java b/src/test/java/dev/jbang/util/TestLockFileUtil.java new file mode 100644 index 0000000000..12613cb663 --- /dev/null +++ b/src/test/java/dev/jbang/util/TestLockFileUtil.java @@ -0,0 +1,20 @@ +package dev.jbang.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; + +public class TestLockFileUtil { + + @Test + void writesAndReadsDigestAndSources() throws Exception { + Path tmp = Files.createTempFile("jbang-lock", ".lock"); + LockFileUtil.write(tmp, "alias@catalog", "sha256:abcdef123456", List.of("a.java", "b.java")); + assertEquals("sha256:abcdef123456", LockFileUtil.readDigest(tmp, "alias@catalog")); + assertEquals(List.of("a.java", "b.java"), LockFileUtil.readSources(tmp, "alias@catalog")); + } +} From 318ad2314f29d4293e888ba5afeae32289bc62b4 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 08/15] feat(lock): enforce locked source manifest matching in run Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- src/main/java/dev/jbang/cli/Run.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/dev/jbang/cli/Run.java b/src/main/java/dev/jbang/cli/Run.java index b9a177f318..de2d240950 100644 --- a/src/main/java/dev/jbang/cli/Run.java +++ b/src/main/java/dev/jbang/cli/Run.java @@ -103,8 +103,10 @@ public Integer doCall() throws IOException { Path effectiveLockFile = lockFile != null ? lockFile : Util.getCwd().resolve(".jbang.lock"); String actualDigest = digestResource(prj, "sha256"); String lockDigest = null; + List lockSources = Collections.emptyList(); if (locked || lockWrite) { lockDigest = LockFileUtil.readDigest(effectiveLockFile, scriptOrFile); + lockSources = LockFileUtil.readSources(effectiveLockFile, scriptOrFile); } if (verifyDigest != null) { @@ -119,6 +121,17 @@ public Integer doCall() throws IOException { "No lock entry for reference: " + scriptOrFile + " in " + effectiveLockFile, null); } verifyDigestSpec(actualDigest, lockDigest, "lockfile"); + if (!lockSources.isEmpty()) { + Set expected = new LinkedHashSet<>(lockSources); + Set actual = prj.getMainSourceSet().getSources().stream() + .map(s -> s.getOriginalResource() == null ? "" : s.getOriginalResource()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (!actual.equals(expected)) { + throw new ExitException(EXIT_INVALID_INPUT, + "Locked sources mismatch for " + scriptOrFile + ". Expected " + expected + " but got " + actual, + null); + } + } } if (lockWrite) { LockFileUtil.writeDigest(effectiveLockFile, scriptOrFile, actualDigest); From 33bd25986e181f6487fe25d61f27043b1bcac640 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 09/15] fix(lock): treat bare #suffix as sha256 checksum prefix Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- src/main/java/dev/jbang/cli/Run.java | 6 ++---- src/test/java/dev/jbang/cli/TestRunChecksum.java | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/main/java/dev/jbang/cli/Run.java b/src/main/java/dev/jbang/cli/Run.java index de2d240950..f87624b644 100644 --- a/src/main/java/dev/jbang/cli/Run.java +++ b/src/main/java/dev/jbang/cli/Run.java @@ -237,10 +237,8 @@ static RefWithChecksum splitRefAndChecksum(String ref) { if (idx < 0 || idx == ref.length() - 1) { return new RefWithChecksum(ref, null); } - String digest = ref.substring(idx + 1); - if (!digest.contains(":")) { - return new RefWithChecksum(ref, null); - } + String suffix = ref.substring(idx + 1); + String digest = suffix.contains(":") ? suffix : "sha256:" + suffix; return new RefWithChecksum(ref.substring(0, idx), digest); } diff --git a/src/test/java/dev/jbang/cli/TestRunChecksum.java b/src/test/java/dev/jbang/cli/TestRunChecksum.java index f8d0c197db..8e84f3c74b 100644 --- a/src/test/java/dev/jbang/cli/TestRunChecksum.java +++ b/src/test/java/dev/jbang/cli/TestRunChecksum.java @@ -15,10 +15,10 @@ void splitRefAndChecksumParsesDigestSuffix() { } @Test - void splitRefAndChecksumLeavesUrlsWithoutDigestUntouched() { - Run.RefWithChecksum ref = Run.splitRefAndChecksum("https://example.org/foo#frag"); - assertEquals("https://example.org/foo#frag", ref.ref); - assertEquals(null, ref.checksum); + void splitRefAndChecksumTreatsBareSuffixAsSha256Prefix() { + Run.RefWithChecksum ref = Run.splitRefAndChecksum("env@jbangdev#blanah"); + assertEquals("env@jbangdev", ref.ref); + assertEquals("sha256:blanah", ref.checksum); } @Test From c291972873f5a5a67adf0f6a1fa74dbe3bcc4671 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 10/15] feat(lock): record and enforce transitive dependency graph in lock Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- src/main/java/dev/jbang/cli/Lock.java | 7 ++++++- src/main/java/dev/jbang/cli/Run.java | 21 ++++++++++++++++--- .../java/dev/jbang/util/LockFileUtil.java | 17 ++++++++++++--- .../java/dev/jbang/util/TestLockFileUtil.java | 7 +++++-- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/main/java/dev/jbang/cli/Lock.java b/src/main/java/dev/jbang/cli/Lock.java index d8a8f11ec5..0494a45eb9 100644 --- a/src/main/java/dev/jbang/cli/Lock.java +++ b/src/main/java/dev/jbang/cli/Lock.java @@ -9,6 +9,7 @@ import java.util.Locale; import java.util.stream.Collectors; +import dev.jbang.source.BuildContext; import dev.jbang.source.Project; import dev.jbang.source.ProjectBuilder; import dev.jbang.util.LockFileUtil; @@ -39,7 +40,11 @@ public Integer doCall() throws IOException { List sources = prj.getMainSourceSet().getSources().stream() .map(s -> relativizeSafe(s.getOriginalResource())) .collect(Collectors.toList()); - LockFileUtil.write(effectiveLockFile, ref, digest, sources); + List deps = BuildContext.forProject(prj).resolveClassPath().getArtifacts().stream() + .map(a -> a.getCoordinate() == null ? "" : a.getCoordinate().toCanonicalForm()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + LockFileUtil.write(effectiveLockFile, ref, digest, sources, deps); info("Locked " + ref + " => " + digest + " in " + effectiveLockFile); return EXIT_OK; } diff --git a/src/main/java/dev/jbang/cli/Run.java b/src/main/java/dev/jbang/cli/Run.java index f87624b644..5a70f64656 100644 --- a/src/main/java/dev/jbang/cli/Run.java +++ b/src/main/java/dev/jbang/cli/Run.java @@ -104,23 +104,25 @@ public Integer doCall() throws IOException { String actualDigest = digestResource(prj, "sha256"); String lockDigest = null; List lockSources = Collections.emptyList(); + List lockDeps = Collections.emptyList(); if (locked || lockWrite) { lockDigest = LockFileUtil.readDigest(effectiveLockFile, scriptOrFile); lockSources = LockFileUtil.readSources(effectiveLockFile, scriptOrFile); + lockDeps = LockFileUtil.readDeps(effectiveLockFile, scriptOrFile); } if (verifyDigest != null) { - verifyDigestSpec(actualDigest, verifyDigest, "--verify"); + verifyDigestSpec(actualDigest, verifyDigest, "--verify for " + scriptOrFile); } if (refWithChecksum != null && refWithChecksum.checksum != null) { - verifyDigestSpec(actualDigest, refWithChecksum.checksum, "reference checksum"); + verifyDigestSpec(actualDigest, refWithChecksum.checksum, "reference checksum for " + scriptOrFile); } if (locked) { if (lockDigest == null) { throw new ExitException(EXIT_INVALID_INPUT, "No lock entry for reference: " + scriptOrFile + " in " + effectiveLockFile, null); } - verifyDigestSpec(actualDigest, lockDigest, "lockfile"); + verifyDigestSpec(actualDigest, lockDigest, "lockfile for " + scriptOrFile); if (!lockSources.isEmpty()) { Set expected = new LinkedHashSet<>(lockSources); Set actual = prj.getMainSourceSet().getSources().stream() @@ -132,6 +134,19 @@ public Integer doCall() throws IOException { null); } } + if (!lockDeps.isEmpty()) { + Set expectedDeps = new LinkedHashSet<>(lockDeps); + Set actualDeps = BuildContext.forProject(prj).resolveClassPath().getArtifacts().stream() + .map(a -> a.getCoordinate() == null ? "" : a.getCoordinate().toCanonicalForm()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (!actualDeps.equals(expectedDeps)) { + throw new ExitException(EXIT_INVALID_INPUT, + "Locked dependency graph mismatch for " + scriptOrFile + ". Expected " + expectedDeps + " but got " + + actualDeps, + null); + } + } } if (lockWrite) { LockFileUtil.writeDigest(effectiveLockFile, scriptOrFile, actualDigest); diff --git a/src/main/java/dev/jbang/util/LockFileUtil.java b/src/main/java/dev/jbang/util/LockFileUtil.java index d49307ed7d..198d613f8f 100644 --- a/src/main/java/dev/jbang/util/LockFileUtil.java +++ b/src/main/java/dev/jbang/util/LockFileUtil.java @@ -27,6 +27,14 @@ public static String readDigest(Path lockFile, String ref) throws IOException { } public static List readSources(Path lockFile, String ref) throws IOException { + return readList(lockFile, ref + ".sources"); + } + + public static List readDeps(Path lockFile, String ref) throws IOException { + return readList(lockFile, ref + ".deps"); + } + + private static List readList(Path lockFile, String key) throws IOException { if (!Files.exists(lockFile)) { return java.util.Collections.emptyList(); } @@ -34,7 +42,7 @@ public static List readSources(Path lockFile, String ref) throws IOExcep try (InputStream in = Files.newInputStream(lockFile)) { p.load(in); } - String val = p.getProperty(ref + ".sources"); + String val = p.getProperty(key); if (val == null || val.trim().isEmpty()) { return java.util.Collections.emptyList(); } @@ -42,10 +50,10 @@ public static List readSources(Path lockFile, String ref) throws IOExcep } public static void writeDigest(Path lockFile, String ref, String digest) throws IOException { - write(lockFile, ref, digest, null); + write(lockFile, ref, digest, null, null); } - public static void write(Path lockFile, String ref, String digest, List sources) throws IOException { + public static void write(Path lockFile, String ref, String digest, List sources, List deps) throws IOException { Properties p = new Properties(); if (Files.exists(lockFile)) { try (InputStream in = Files.newInputStream(lockFile)) { @@ -56,6 +64,9 @@ public static void write(Path lockFile, String ref, String digest, List if (sources != null && !sources.isEmpty()) { p.setProperty(ref + ".sources", String.join(",", sources)); } + if (deps != null && !deps.isEmpty()) { + p.setProperty(ref + ".deps", String.join(",", deps)); + } if (lockFile.getParent() != null) { Files.createDirectories(lockFile.getParent()); } diff --git a/src/test/java/dev/jbang/util/TestLockFileUtil.java b/src/test/java/dev/jbang/util/TestLockFileUtil.java index 12613cb663..894c9e0051 100644 --- a/src/test/java/dev/jbang/util/TestLockFileUtil.java +++ b/src/test/java/dev/jbang/util/TestLockFileUtil.java @@ -4,6 +4,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Test; @@ -13,8 +14,10 @@ public class TestLockFileUtil { @Test void writesAndReadsDigestAndSources() throws Exception { Path tmp = Files.createTempFile("jbang-lock", ".lock"); - LockFileUtil.write(tmp, "alias@catalog", "sha256:abcdef123456", List.of("a.java", "b.java")); + LockFileUtil.write(tmp, "alias@catalog", "sha256:abcdef123456", Arrays.asList("a.java", "b.java"), + Arrays.asList("g:a:1", "g:b:2")); assertEquals("sha256:abcdef123456", LockFileUtil.readDigest(tmp, "alias@catalog")); - assertEquals(List.of("a.java", "b.java"), LockFileUtil.readSources(tmp, "alias@catalog")); + assertEquals(Arrays.asList("a.java", "b.java"), LockFileUtil.readSources(tmp, "alias@catalog")); + assertEquals(Arrays.asList("g:a:1", "g:b:2"), LockFileUtil.readDeps(tmp, "alias@catalog")); } } From f066c355e6d4d03d780724989be3fe15ff07f78c Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 11/15] test(lock): add lockfile edge-case coverage for missing entries Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- .../java/dev/jbang/util/TestLockFileUtil.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/dev/jbang/util/TestLockFileUtil.java b/src/test/java/dev/jbang/util/TestLockFileUtil.java index 894c9e0051..41ce7e7feb 100644 --- a/src/test/java/dev/jbang/util/TestLockFileUtil.java +++ b/src/test/java/dev/jbang/util/TestLockFileUtil.java @@ -20,4 +20,21 @@ void writesAndReadsDigestAndSources() throws Exception { assertEquals(Arrays.asList("a.java", "b.java"), LockFileUtil.readSources(tmp, "alias@catalog")); assertEquals(Arrays.asList("g:a:1", "g:b:2"), LockFileUtil.readDeps(tmp, "alias@catalog")); } + + @Test + void missingEntriesReturnEmptyCollectionsOrNull() throws Exception { + Path tmp = Files.createTempFile("jbang-lock-empty", ".lock"); + assertEquals(null, LockFileUtil.readDigest(tmp, "missing@ref")); + assertEquals(java.util.Collections.emptyList(), LockFileUtil.readSources(tmp, "missing@ref")); + assertEquals(java.util.Collections.emptyList(), LockFileUtil.readDeps(tmp, "missing@ref")); + } + + @Test + void writeDigestDoesNotRequireSourcesOrDeps() throws Exception { + Path tmp = Files.createTempFile("jbang-lock-digest", ".lock"); + LockFileUtil.writeDigest(tmp, "x:y:z", "sha256:deadbeefdead"); + assertEquals("sha256:deadbeefdead", LockFileUtil.readDigest(tmp, "x:y:z")); + assertEquals(java.util.Collections.emptyList(), LockFileUtil.readSources(tmp, "x:y:z")); + assertEquals(java.util.Collections.emptyList(), LockFileUtil.readDeps(tmp, "x:y:z")); + } } From f31244ca60261a712726bb467f04288d989ec18a Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 12/15] test(lock): add drift edge-case checks for locked sources and deps Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- src/main/java/dev/jbang/cli/Run.java | 21 +++++++------- .../java/dev/jbang/cli/TestRunChecksum.java | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/main/java/dev/jbang/cli/Run.java b/src/main/java/dev/jbang/cli/Run.java index 5a70f64656..410fb5bee9 100644 --- a/src/main/java/dev/jbang/cli/Run.java +++ b/src/main/java/dev/jbang/cli/Run.java @@ -128,11 +128,7 @@ public Integer doCall() throws IOException { Set actual = prj.getMainSourceSet().getSources().stream() .map(s -> s.getOriginalResource() == null ? "" : s.getOriginalResource()) .collect(Collectors.toCollection(LinkedHashSet::new)); - if (!actual.equals(expected)) { - throw new ExitException(EXIT_INVALID_INPUT, - "Locked sources mismatch for " + scriptOrFile + ". Expected " + expected + " but got " + actual, - null); - } + verifyLockedSet("sources", scriptOrFile, expected, actual); } if (!lockDeps.isEmpty()) { Set expectedDeps = new LinkedHashSet<>(lockDeps); @@ -140,12 +136,7 @@ public Integer doCall() throws IOException { .map(a -> a.getCoordinate() == null ? "" : a.getCoordinate().toCanonicalForm()) .filter(s -> !s.isEmpty()) .collect(Collectors.toCollection(LinkedHashSet::new)); - if (!actualDeps.equals(expectedDeps)) { - throw new ExitException(EXIT_INVALID_INPUT, - "Locked dependency graph mismatch for " + scriptOrFile + ". Expected " + expectedDeps + " but got " - + actualDeps, - null); - } + verifyLockedSet("dependency graph", scriptOrFile, expectedDeps, actualDeps); } } if (lockWrite) { @@ -281,6 +272,14 @@ private static String toHex(byte[] bytes) { return sb.toString(); } + static void verifyLockedSet(String kind, String ref, Set expected, Set actual) { + if (!actual.equals(expected)) { + throw new ExitException(EXIT_INVALID_INPUT, + "Locked " + kind + " mismatch for " + ref + ". Expected " + expected + " but got " + actual, + null); + } + } + static void verifyDigestSpec(String actualDigest, String expectedDigest, String source) { String[] actualParts = actualDigest.split(":", 2); String[] expectedParts = expectedDigest.split(":", 2); diff --git a/src/test/java/dev/jbang/cli/TestRunChecksum.java b/src/test/java/dev/jbang/cli/TestRunChecksum.java index 8e84f3c74b..007f5852e2 100644 --- a/src/test/java/dev/jbang/cli/TestRunChecksum.java +++ b/src/test/java/dev/jbang/cli/TestRunChecksum.java @@ -2,6 +2,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashSet; +import java.util.Set; import org.junit.jupiter.api.Test; @@ -37,4 +41,28 @@ void verifyDigestSpecRejectsMismatch() { assertThrows(ExitException.class, () -> Run.verifyDigestSpec("sha256:abcdef1234567890", "sha256:bbbbbbbbbbbb", "test")); } + + @Test + void verifyLockedSetRejectsDependencyDrift() { + Set expected = new LinkedHashSet<>(); + expected.add("a:b:jar:1"); + expected.add("x:y:jar:2"); + Set actual = new LinkedHashSet<>(); + actual.add("a:b:jar:1"); + actual.add("x:y:jar:3"); + ExitException ex = assertThrows(ExitException.class, + () -> Run.verifyLockedSet("dependency graph", "demo@cat", expected, actual)); + assertTrue(ex.getMessage().contains("Locked dependency graph mismatch for demo@cat")); + } + + @Test + void verifyLockedSetRejectsSourcesDrift() { + Set expected = new LinkedHashSet<>(); + expected.add("https://example/a.java"); + Set actual = new LinkedHashSet<>(); + actual.add("https://example/b.java"); + ExitException ex = assertThrows(ExitException.class, + () -> Run.verifyLockedSet("sources", "env@jbangdev", expected, actual)); + assertTrue(ex.getMessage().contains("Locked sources mismatch for env@jbangdev")); + } } From bf86270234d53c771eab30d0534ae619fd4923f3 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 13/15] docs(lock): add locking guide and CLI help pages Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- docs/modules/ROOT/pages/locking.adoc | 62 ++++++++++++++++++++++++ docs/modules/ROOT/partials/nav.adoc | 1 + docs/modules/cli/pages/jbang-lock.adoc | 33 +++++++++++++ docs/modules/cli/pages/jbang-run.adoc | 12 +++++ docs/modules/cli/pages/jbang.adoc | 3 ++ docs/modules/cli/partials/nav-jbang.adoc | 3 +- 6 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 docs/modules/ROOT/pages/locking.adoc create mode 100644 docs/modules/cli/pages/jbang-lock.adoc diff --git a/docs/modules/ROOT/pages/locking.adoc b/docs/modules/ROOT/pages/locking.adoc new file mode 100644 index 0000000000..3d77737c4f --- /dev/null +++ b/docs/modules/ROOT/pages/locking.adoc @@ -0,0 +1,62 @@ += Locking & Checksum Verification + +JBang supports lightweight integrity and reproducibility checks via digests and lock files. + +== Verify a single ref + +Use an inline digest suffix: + +[source,bash] +---- +jbang run env@jbangdev#sha256:35a0c5c82720 +---- + +You can also pass `--verify` explicitly: + +[source,bash] +---- +jbang run --verify sha256:35a0c5c82720 env@jbangdev +---- + +NOTE: Digest prefixes are supported (minimum 12 hex chars). + +== Generate lock entries + +Create/update `.jbang.lock` for a ref: + +[source,bash] +---- +jbang lock env@jbangdev +jbang lock dev.tamboui:tamboui-toolkit:0.1.0 +---- + +By default JBang stores: + +- `ref=sha256:...` (root digest) +- `ref.sources=...` (resolved sources, when applicable) +- `ref.deps=...` (resolved transitive dependency coordinates, when applicable) + +== Enforce lock checks + +Run in strict locked mode: + +[source,bash] +---- +jbang --locked env@jbangdev +jbang --locked dev.tamboui:tamboui-toolkit:0.1.0 +---- + +Locked mode validates: + +- root digest +- source manifest (if present) +- dependency graph (if present) + +If any drift is detected, JBang fails with a clear mismatch error. + +== Use another lock file + +[source,bash] +---- +jbang --lock-file /path/to/my.lock --locked env@jbangdev +---- diff --git a/docs/modules/ROOT/partials/nav.adoc b/docs/modules/ROOT/partials/nav.adoc index 719983b6e1..0e63fdb4c6 100644 --- a/docs/modules/ROOT/partials/nav.adoc +++ b/docs/modules/ROOT/partials/nav.adoc @@ -15,6 +15,7 @@ * Running & Execution ** xref:jbang:ROOT:execution-options.adoc[Execution Options] +** xref:jbang:ROOT:locking.adoc[Locking & Checksums] ** xref:jbang:ROOT:remote-execution.adoc[Remote Execution] ** xref:jbang:ROOT:native-images.adoc[Native Images] ** xref:jbang:ROOT:self-contained-executables.adoc[Self-Contained Executables] diff --git a/docs/modules/cli/pages/jbang-lock.adoc b/docs/modules/cli/pages/jbang-lock.adoc new file mode 100644 index 0000000000..7df9feff80 --- /dev/null +++ b/docs/modules/cli/pages/jbang-lock.adoc @@ -0,0 +1,33 @@ +// Manual page for new lock command (until picocli docs are regenerated) +:doctype: manpage +:manmanual: jbang Manual +:man-linkstyle: pass:[blue R < >] += jbang-lock(1) + +== Name + +jbang-lock - Generate or refresh lock entries for script references. + +== Synopsis + +*jbang lock* [*--lock-file*=__] [*--algorithm*=__] [__] + +== Description + +Resolves the given reference and writes lock metadata to a lock file. + +Entries include: + +- Root digest (`ref=sha256:...`) +- Resolved source manifest (`ref.sources=...`) when available +- Resolved transitive dependency coordinates (`ref.deps=...`) when available + +== Options + +*--algorithm*=__: `sha256` by default. + +*--lock-file*=__: path to lock file, default is `.jbang.lock`. + +== Arguments + +[__]: reference to lock (alias, URL, GAV, file, etc.). diff --git a/docs/modules/cli/pages/jbang-run.adoc b/docs/modules/cli/pages/jbang-run.adoc index a655ea0b61..8d667554a9 100644 --- a/docs/modules/cli/pages/jbang-run.adoc +++ b/docs/modules/cli/pages/jbang-run.adoc @@ -122,6 +122,15 @@ Builds and runs provided script. (default command) *--manifest*=__:: +*--lock-file*=__:: + Path to lock file (default: .jbang.lock) + +*--lock-write*:: + Write/update digest entry in the lock file for this ref. + +*--locked*:: + Require matching lock entry and enforce lock checks. + *--module*[=__]:: Treat resource as a module. Optionally with the given module name @@ -149,6 +158,9 @@ Builds and runs provided script. (default command) *-T*, *--source-type*=__:: Force input to be interpreted as the given type. Can be: java, jshell, groovy, kotlin, or markdown +*--verify*=__:: + Verify script content with digest, e.g. `sha256:abc123`. + *--verbose*:: jbang will be verbose on what it does. diff --git a/docs/modules/cli/pages/jbang.adoc b/docs/modules/cli/pages/jbang.adoc index ae7fce1719..c76791242a 100644 --- a/docs/modules/cli/pages/jbang.adoc +++ b/docs/modules/cli/pages/jbang.adoc @@ -134,6 +134,9 @@ xref:jbang:cli:jbang-export.adoc[*export*]:: xref:jbang:cli:jbang-config.adoc[*config*]:: Read and write configuration options. +xref:jbang:cli:jbang-lock.adoc[*lock*]:: + Generate or refresh lock entries for script references. + // end::picocli-generated-man-section-commands[] // tag::picocli-generated-man-section-exit-status[] diff --git a/docs/modules/cli/partials/nav-jbang.adoc b/docs/modules/cli/partials/nav-jbang.adoc index 93e28df659..c1f59f6e16 100644 --- a/docs/modules/cli/partials/nav-jbang.adoc +++ b/docs/modules/cli/partials/nav-jbang.adoc @@ -57,4 +57,5 @@ *** xref:jbang:cli:jbang-config-get.adoc[get] *** xref:jbang:cli:jbang-config-set.adoc[set] *** xref:jbang:cli:jbang-config-unset.adoc[unset] -*** xref:jbang:cli:jbang-config-list.adoc[list] \ No newline at end of file +*** xref:jbang:cli:jbang-config-list.adoc[list] +** xref:jbang:cli:jbang-lock.adoc[lock] \ No newline at end of file From e86d9f74ff8f59a0ef2c1910c438e7cf8e89f220 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Mon, 23 Feb 2026 04:39:05 -0800 Subject: [PATCH 14/15] feat(lock): use locked source manifest as include-source override Assisted-by: Haley (openai-codex/gpt-5.3-codex) --- src/main/java/dev/jbang/cli/Run.java | 10 +++++++++- .../java/dev/jbang/source/ProjectBuilder.java | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/dev/jbang/cli/Run.java b/src/main/java/dev/jbang/cli/Run.java index 410fb5bee9..e47912c2cf 100644 --- a/src/main/java/dev/jbang/cli/Run.java +++ b/src/main/java/dev/jbang/cli/Run.java @@ -76,7 +76,16 @@ public Integer doCall() throws IOException { scriptOrFile = refWithChecksum.ref; } + Path effectiveLockFile = lockFile != null ? lockFile : Util.getCwd().resolve(".jbang.lock"); + List preLockSources = Collections.emptyList(); + if (locked && scriptOrFile != null) { + preLockSources = LockFileUtil.readSources(effectiveLockFile, scriptOrFile); + } + ProjectBuilder pb = createProjectBuilderForRun(); + if (!preLockSources.isEmpty()) { + pb.lockedSourcesOverride(preLockSources); + } Project prj; if (literalScript.isPresent()) { @@ -100,7 +109,6 @@ public Integer doCall() throws IOException { } if (!literalScript.isPresent() && scriptOrFile != null) { - Path effectiveLockFile = lockFile != null ? lockFile : Util.getCwd().resolve(".jbang.lock"); String actualDigest = digestResource(prj, "sha256"); String lockDigest = null; List lockSources = Collections.emptyList(); diff --git a/src/main/java/dev/jbang/source/ProjectBuilder.java b/src/main/java/dev/jbang/source/ProjectBuilder.java index fbf5bad243..df25bc86a6 100644 --- a/src/main/java/dev/jbang/source/ProjectBuilder.java +++ b/src/main/java/dev/jbang/source/ProjectBuilder.java @@ -66,6 +66,7 @@ */ public class ProjectBuilder { private List additionalSources = new ArrayList<>(); + private List lockedSourcesOverride = Collections.emptyList(); private List additionalResources = new ArrayList<>(); private List additionalDeps = new ArrayList<>(); private List additionalRepos = new ArrayList<>(); @@ -116,6 +117,15 @@ public ProjectBuilder additionalSources(List sources) { return this; } + public ProjectBuilder lockedSourcesOverride(List sources) { + if (sources != null) { + this.lockedSourcesOverride = new ArrayList<>(sources); + } else { + this.lockedSourcesOverride = Collections.emptyList(); + } + return this; + } + public ProjectBuilder additionalResources(List resources) { if (resources != null) { this.additionalResources = new ArrayList<>(resources); @@ -684,7 +694,12 @@ private Project updateProject(Source src, Project prj, ResourceResolver resolver prj.addSubProject(new ProjectBuilder(buildRefs).build(subRef)); } ResourceResolver sibRes2 = getSiblingResolver(srcRef, resolver); - List includedSources = allToSource(src.getDirectives().sources(), srcRef, sibRes2); + List sourceRefs = src.getDirectives().sources(); + if (!lockedSourcesOverride.isEmpty() && prj.getMainSource() != null + && srcRef.equals(prj.getMainSource().getResourceRef())) { + sourceRefs = lockedSourcesOverride; + } + List includedSources = allToSource(sourceRefs, srcRef, sibRes2); for (Source includedSource : includedSources) { updateProject(includedSource, prj, resolver); } From 73c27e7d961977fafcd013eb0e85f35e518e1f61 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Fri, 13 Mar 2026 18:09:41 +0100 Subject: [PATCH 15/15] Add Usage spec export command --- docs/modules/cli/pages/jbang-usage.adoc | 47 +++ docs/modules/cli/partials/nav-jbang.adoc | 11 +- src/main/java/dev/jbang/cli/JBang.java | 6 +- src/main/java/dev/jbang/cli/Usage.java | 107 ++++++ .../jbang/cli/usage/UsageSpecGenerator.java | 332 ++++++++++++++++++ .../cli/usage/UsageSpecGeneratorTest.java | 49 +++ 6 files changed, 546 insertions(+), 6 deletions(-) create mode 100644 docs/modules/cli/pages/jbang-usage.adoc create mode 100644 src/main/java/dev/jbang/cli/Usage.java create mode 100644 src/main/java/dev/jbang/cli/usage/UsageSpecGenerator.java create mode 100644 src/test/java/dev/jbang/cli/usage/UsageSpecGeneratorTest.java diff --git a/docs/modules/cli/pages/jbang-usage.adoc b/docs/modules/cli/pages/jbang-usage.adoc new file mode 100644 index 0000000000..81a6e35d40 --- /dev/null +++ b/docs/modules/cli/pages/jbang-usage.adoc @@ -0,0 +1,47 @@ += jbang usage +:doctype: book +:toc: macro +:toclevels: 2 +:icons: font + +== Name + +`jbang usage` - Export the current jbang command tree to a https://usage.jdx.dev/[Usage] KDL specification. + +== Synopsis + +``` +jbang usage [--include-hidden] [--root ] [-o ] +``` + +All global jbang options (for example `--config`, `--insecure`, `--offline`, `--fresh`, `--quiet`, `--verbose`, `--stacktrace`) are also accepted. + +== Description + +The `usage` command inspects the live picocli command model and emits a Usage-spec-compliant KDL document. That document can then be fed to the Usage tooling to generate completions, documentation, scripts, and more while staying in sync with jbang. + +* By default the entire CLI (root `jbang` command and all subcommands) is exported. +* Use `--root` to limit the export to a specific subcommand hierarchy (for example `jbang usage --root "catalog add"`). +* Use `--include-hidden` when you also want hidden commands/options in the spec. +* When `-o/--out` is omitted the spec is printed to stdout, making it easy to pipe directly into `usage markdown`, `usage completions`, etc. + +== Options + +[cols="1,3"] +|=== +|`-o`, `--out `|Write the generated KDL to the provided file instead of stdout. Parent directories are created automatically. +|`--include-hidden`|Include commands, flags, and positional arguments that are marked as hidden. +|`--root `|Only export the specified subcommand path. Tokens are space separated (e.g. `"catalog add"`). +|=== + +== Examples + +.Export the full CLI and immediately render it to Markdown via the Usage CLI +``` +jbang usage | usage markdown > CLI.md +``` + +.Export only the `jbang run` command tree and write it to a file +``` +jbang usage --root run --out build/jbang-run.usage.kdl +``` diff --git a/docs/modules/cli/partials/nav-jbang.adoc b/docs/modules/cli/partials/nav-jbang.adoc index c1f59f6e16..632fc6717a 100644 --- a/docs/modules/cli/partials/nav-jbang.adoc +++ b/docs/modules/cli/partials/nav-jbang.adoc @@ -24,13 +24,15 @@ ** xref:jbang:cli:jbang-cache.adoc[cache] *** xref:jbang:cli:jbang-cache-clear.adoc[clear] ** xref:jbang:cli:jbang-completion.adoc[completion] +** xref:jbang:cli:jbang-usage.adoc[usage] ** xref:jbang:cli:jbang-jdk.adoc[jdk] *** xref:jbang:cli:jbang-jdk-default.adoc[default] +*** xref:jbang:cli:jbang-jdk-exec.adoc[exec, x] *** xref:jbang:cli:jbang-jdk-home.adoc[home] -*** xref:jbang:cli:jbang-jdk-install.adoc[install] +*** xref:jbang:cli:jbang-jdk-install.adoc[install, i] *** xref:jbang:cli:jbang-jdk-java-env.adoc[java-env, env] -*** xref:jbang:cli:jbang-jdk-list.adoc[list] -*** xref:jbang:cli:jbang-jdk-uninstall.adoc[uninstall] +*** xref:jbang:cli:jbang-jdk-list.adoc[list, l] +*** xref:jbang:cli:jbang-jdk-uninstall.adoc[uninstall, u] ** xref:jbang:cli:jbang-version.adoc[version] ** xref:jbang:cli:jbang-wrapper.adoc[wrapper] *** xref:jbang:cli:jbang-wrapper-install.adoc[install] @@ -58,4 +60,7 @@ *** xref:jbang:cli:jbang-config-set.adoc[set] *** xref:jbang:cli:jbang-config-unset.adoc[unset] *** xref:jbang:cli:jbang-config-list.adoc[list] +** xref:jbang:cli:jbang-deps.adoc[deps] +*** xref:jbang:cli:jbang-deps-add.adoc[add] +*** xref:jbang:cli:jbang-deps-search.adoc[search] ** xref:jbang:cli:jbang-lock.adoc[lock] \ No newline at end of file diff --git a/src/main/java/dev/jbang/cli/JBang.java b/src/main/java/dev/jbang/cli/JBang.java index d86f87e4a6..69cdaade94 100644 --- a/src/main/java/dev/jbang/cli/JBang.java +++ b/src/main/java/dev/jbang/cli/JBang.java @@ -60,7 +60,7 @@ "" }, versionProvider = VersionProvider.class, subcommands = { Run.class, Build.class, Edit.class, Init.class, Alias.class, Template.class, Catalog.class, Trust.class, Cache.class, Completion.class, Jdk.class, Version.class, Wrapper.class, Info.class, App.class, - Export.class, Config.class, Deps.class, Lock.class }) + Export.class, Config.class, Deps.class, Lock.class, Usage.class }) public class JBang extends BaseCommand { @CommandLine.Option(names = { "-V", @@ -294,9 +294,9 @@ private Map> sections() { sections = new LinkedHashMap<>(); sections.put("Essentials", asList("run", "build")); sections.put("Editing", asList("init", "edit", "deps")); - sections.put("Caching", asList("cache", "export", "jdk")); + sections.put("Caching", asList("cache", "export", "jdk", "lock")); sections.put("Configuration", asList("config", "trust", "alias", "template", "catalog", "app")); - sections.put("Other", asList("completion", "info", "version", "wrapper")); + sections.put("Other", asList("completion", "usage", "info", "version", "wrapper")); Map cmds = externals(); if (!cmds.isEmpty()) { sections.put("External", new ArrayList<>(cmds.keySet())); diff --git a/src/main/java/dev/jbang/cli/Usage.java b/src/main/java/dev/jbang/cli/Usage.java new file mode 100644 index 0000000000..04f02a6c3a --- /dev/null +++ b/src/main/java/dev/jbang/cli/Usage.java @@ -0,0 +1,107 @@ +package dev.jbang.cli; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import dev.jbang.cli.usage.UsageSpecGenerator; + +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Option; + +@Command(name = "usage", description = "Generate a Usage (usage.jdx.dev) KDL spec for the jbang CLI.") +public class Usage extends BaseCommand { + + @Option(names = "--out", paramLabel = "", description = "Write the spec to the given file (defaults to stdout)") + Path output; + + @Option(names = "--include-hidden", description = "Include commands and options marked as hidden") + boolean includeHidden; + + @Option(names = "--root", paramLabel = "", description = "Limit the export to a specific subcommand path (e.g. 'catalog add')") + String rootPath; + + @Override + public Integer doCall() throws IOException { + CommandLine topLevel = topLevel(spec.commandLine()); + CommandLine target = resolveTarget(topLevel, rootPath); + UsageSpecGenerator generator = new UsageSpecGenerator(); + UsageSpecGenerator.Options options = new UsageSpecGenerator.Options(includeHidden); + String result = generator.generate(target, options); + write(result); + return EXIT_OK; + } + + private CommandLine topLevel(CommandLine commandLine) { + CommandLine current = commandLine; + while (current.getParent() != null) { + current = current.getParent(); + } + return current; + } + + private CommandLine resolveTarget(CommandLine root, String path) { + if (path == null || path.trim().isEmpty()) { + return root; + } + List segments = new ArrayList<>(Arrays.asList(path.trim().split("\\s+"))); + if (!segments.isEmpty() && segments.get(0).equalsIgnoreCase(root.getCommandSpec().name())) { + segments.remove(0); + } + CommandLine current = root; + for (String segment : segments) { + current = findSubcommand(current, segment) + .orElseThrow(() -> new ExitException(EXIT_INVALID_INPUT, + "Unknown subcommand '" + segment + "' in path '" + path + "'")); + } + return current; + } + + private Optional findSubcommand(CommandLine parent, String name) { + Map unique = new LinkedHashMap<>(); + for (CommandLine sub : parent.getSubcommands().values()) { + unique.putIfAbsent(sub.getCommandSpec().name(), sub); + } + for (CommandLine sub : unique.values()) { + CommandSpec subSpec = sub.getCommandSpec(); + if (!includeHidden && subSpec.usageMessage().hidden()) { + continue; + } + if (subSpec.name().equals(name)) { + return Optional.of(sub); + } + for (String alias : subSpec.aliases()) { + if (alias.equals(name)) { + return Optional.of(sub); + } + } + } + return Optional.empty(); + } + + private void write(String contents) throws IOException { + if (output == null) { + realOut.print(contents); + if (!contents.endsWith(System.lineSeparator())) { + realOut.println(); + } + realOut.flush(); + return; + } + Path target = output.toAbsolutePath(); + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.write(target, contents.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/dev/jbang/cli/usage/UsageSpecGenerator.java b/src/main/java/dev/jbang/cli/usage/UsageSpecGenerator.java new file mode 100644 index 0000000000..d87d68debb --- /dev/null +++ b/src/main/java/dev/jbang/cli/usage/UsageSpecGenerator.java @@ -0,0 +1,332 @@ +package dev.jbang.cli.usage; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Collectors; + +import dev.jbang.util.Util; + +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; +import picocli.CommandLine.Model.PositionalParamSpec; +import picocli.CommandLine.ScopeType; + +/** + * Converts a picocli {@link CommandLine} definition into a Usage + * (usage.jdx.dev) KDL specification. + */ +public class UsageSpecGenerator { + + public static final class Options { + private final boolean includeHidden; + + public Options(boolean includeHidden) { + this.includeHidden = includeHidden; + } + + public boolean includeHidden() { + return includeHidden; + } + } + + public String generate(CommandLine commandLine, Options options) { + Writer writer = new Writer(options); + writer.writeRoot(commandLine); + return writer.toString(); + } + + private static final class Writer { + private final Options options; + private final StringBuilder out = new StringBuilder(); + + Writer(Options options) { + this.options = options; + } + + void writeRoot(CommandLine commandLine) { + CommandSpec spec = commandLine.getCommandSpec(); + writeMetadata(spec); + out.append('\n'); + boolean wroteOptions = writeOptions(spec, 0); + boolean wrotePositionals = writePositionals(spec, 0); + boolean wroteCommands = writeSubcommands(commandLine, 0); + if (!wroteOptions && !wrotePositionals && !wroteCommands) { + // remove extra newline if nothing else was written + trimTrailingNewline(); + } + } + + private void trimTrailingNewline() { + if (out.length() > 0 && out.charAt(out.length() - 1) == '\n') { + out.deleteCharAt(out.length() - 1); + } + } + + private void writeMetadata(CommandSpec spec) { + String friendlyName = firstNonBlank(spec.name(), spec.qualifiedName(" ")); + String bin = commandPath(spec); + String about = description(spec); + appendLine(0, "name " + quoted(friendlyName)); + appendLine(0, "bin " + quoted(bin)); + if (!about.isEmpty()) { + appendLine(0, "about " + quoted(about)); + } + String version = Util.getJBangVersion(); + if (!Util.isNullOrEmptyString(version)) { + appendLine(0, "version " + quoted(version)); + } + List aliases = commandAliases(spec); + for (String alias : aliases) { + appendLine(0, "alias " + quoted(alias)); + } + } + + private boolean writeOptions(CommandSpec spec, int indent) { + List options = spec.options() + .stream() + .filter(opt -> this.options.includeHidden() || !opt.hidden()) + .sorted((a, b) -> primaryName(a).compareTo(primaryName(b))) + .collect(Collectors.toList()); + for (OptionSpec option : options) { + appendLine(indent, formatFlag(option)); + } + return !options.isEmpty(); + } + + private String formatFlag(OptionSpec option) { + StringBuilder line = new StringBuilder(); + line.append("flag "); + line.append(quoted(flagLabel(option))); + String help = description(option); + if (!help.isEmpty()) { + line.append(" help=").append(quoted(help)); + } + if (option.required()) { + line.append(" required=#true"); + } + if (option.scopeType() == ScopeType.INHERIT) { + line.append(" global=#true"); + } + if (isCountable(option)) { + line.append(" count=#true"); + } + String defaultValue = option.defaultValue(); + if (defaultValue != null) { + line.append(" default=").append(quoted(defaultValue)); + } + if (option.hidden()) { + line.append(" hide=#true"); + } + return line.toString(); + } + + private boolean writePositionals(CommandSpec spec, int indent) { + List params = spec.positionalParameters() + .stream() + .filter(param -> this.options.includeHidden() || !param.hidden()) + .sorted((a, b) -> Integer.compare(a.index().min, b.index().min)) + .collect(Collectors.toList()); + for (PositionalParamSpec param : params) { + appendLine(indent, formatArg(param)); + } + return !params.isEmpty(); + } + + private String formatArg(PositionalParamSpec param) { + StringBuilder line = new StringBuilder(); + line.append("arg "); + line.append(quoted(argLabel(param))); + String help = description(param); + if (!help.isEmpty()) { + line.append(" help=").append(quoted(help)); + } + if (param.required()) { + line.append(" required=#true"); + } + if (isCountable(param)) { + line.append(" repeat=#true"); + } + String defaultValue = param.defaultValue(); + if (defaultValue != null) { + line.append(" default=").append(quoted(defaultValue)); + } + if (param.hidden()) { + line.append(" hide=#true"); + } + return line.toString(); + } + + private boolean writeSubcommands(CommandLine commandLine, int indent) { + List subcommands = collectSubcommands(commandLine); + for (CommandLine sub : subcommands) { + writeCommand(sub, indent); + } + return !subcommands.isEmpty(); + } + + private void writeCommand(CommandLine commandLine, int indent) { + CommandSpec spec = commandLine.getCommandSpec(); + StringBuilder header = new StringBuilder(); + header.append("cmd "); + header.append(quoted(spec.name())); + String help = description(spec); + if (!help.isEmpty()) { + header.append(" help=").append(quoted(help)); + } + appendLine(indent, header.append(" {").toString()); + List aliases = commandAliases(spec); + for (String alias : aliases) { + appendLine(indent + 1, "alias " + quoted(alias)); + } + writeOptions(spec, indent + 1); + writePositionals(spec, indent + 1); + writeSubcommands(commandLine, indent + 1); + appendLine(indent, "}"); + } + + private List collectSubcommands(CommandLine commandLine) { + Map uniqueByName = new TreeMap<>(); + for (CommandLine sub : commandLine.getSubcommands().values()) { + CommandSpec subSpec = sub.getCommandSpec(); + if (!options.includeHidden() && subSpec.usageMessage().hidden()) { + continue; + } + uniqueByName.putIfAbsent(subSpec.name(), sub); + } + return new ArrayList<>(uniqueByName.values()); + } + + private String flagLabel(OptionSpec option) { + String joinedNames = String.join(" ", option.names()); + if (option.arity().max > 0) { + String paramLabel = paramLabel(option.paramLabel(), option.arity().min > 0); + return String.join(" ", joinedNames, paramLabel).trim(); + } + return joinedNames; + } + + private String argLabel(PositionalParamSpec param) { + String label = param.paramLabel(); + return paramLabel(label, param.required()); + } + + private String paramLabel(String rawLabel, boolean required) { + String value = Objects.toString(rawLabel, "value").trim(); + if (value.isEmpty()) { + value = "value"; + } + boolean wrapped = value.startsWith("<") || value.startsWith("["); + if (wrapped) { + return value; + } + return required ? "<" + value + ">" : "[" + value + "]"; + } + + private boolean isCountable(OptionSpec option) { + return option.isMultiValue() || option.arity().max > 1 || option.arity().max == Integer.MAX_VALUE; + } + + private boolean isCountable(PositionalParamSpec param) { + return param.isMultiValue() || param.arity().max > 1 || param.arity().max == Integer.MAX_VALUE; + } + + private String primaryName(OptionSpec option) { + String[] names = option.names(); + return names.length == 0 ? option.longestName() : names[0]; + } + + private List commandAliases(CommandSpec spec) { + List names = new ArrayList<>(spec.names()); + if (names.isEmpty()) { + return Collections.emptyList(); + } + return names.subList(1, names.size()); + } + + private String description(OptionSpec option) { + return join(option.description()); + } + + private String description(PositionalParamSpec param) { + return join(param.description()); + } + + private String description(CommandSpec spec) { + String desc = join(spec.usageMessage().description()); + if (!desc.isEmpty()) { + return desc; + } + return join(spec.usageMessage().header()); + } + + private String join(String[] values) { + if (values == null || values.length == 0) { + return ""; + } + return Arrays.stream(values) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.joining(" ")); + } + + private void appendLine(int indent, String line) { + if (indent > 0) { + for (int i = 0; i < indent; i++) { + out.append(" "); + } + } + out.append(line); + out.append('\n'); + } + + private String quoted(String value) { + return "\"" + escape(value) + "\""; + } + + private String escape(String value) { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n"); + } + + private String commandPath(CommandSpec spec) { + Deque names = new ArrayDeque<>(); + CommandSpec current = spec; + while (current != null && current.name() != null && !current.name().isEmpty()) { + names.addFirst(current.name()); + current = current.parent(); + } + return String.join(" ", names); + } + + private String firstNonBlank(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (hasText(value)) { + return value; + } + } + return ""; + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + @Override + public String toString() { + return out.toString(); + } + } +} diff --git a/src/test/java/dev/jbang/cli/usage/UsageSpecGeneratorTest.java b/src/test/java/dev/jbang/cli/usage/UsageSpecGeneratorTest.java new file mode 100644 index 0000000000..eb35b0ace2 --- /dev/null +++ b/src/test/java/dev/jbang/cli/usage/UsageSpecGeneratorTest.java @@ -0,0 +1,49 @@ +package dev.jbang.cli.usage; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ScopeType; + +class UsageSpecGeneratorTest { + + @Command(name = "root", description = "Root command", subcommands = Child.class) + static class Root { + + @Option(names = { "-v", "--verbose" }, description = "Verbose logging", scope = ScopeType.INHERIT) + boolean verbose; + + @Parameters(index = "0", paramLabel = "input", description = "Input file") + String input; + } + + @Command(name = "child", description = "Child command", aliases = { "c" }) + static class Child { + + @Option(names = "--force", description = "Force execution") + boolean force; + + @Parameters(paramLabel = "item", description = "Optional item", arity = "0..1") + String item; + } + + @Test + void generatesUsageSpec() { + CommandLine commandLine = new CommandLine(new Root()); + UsageSpecGenerator generator = new UsageSpecGenerator(); + String output = generator.generate(commandLine, new UsageSpecGenerator.Options(false)); + + assertTrue(output.contains("name \"root\"")); + assertTrue(output.contains("bin \"root\"")); + assertTrue(output.contains("flag \"-v --verbose\"")); + assertTrue(output.contains("global=#true")); + assertTrue(output.contains("arg \"\"")); + assertTrue(output.contains("cmd \"child\"")); + assertTrue(output.contains("alias \"c\"")); + } +}