diff --git a/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java b/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java index 4edc4046..245b1a3f 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java +++ b/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java @@ -26,6 +26,7 @@ import net.lingala.zip4j.ZipFile; import net.lingala.zip4j.model.FileHeader; import org.apache.commons.io.FileUtils; +import org.json.JSONArray; import org.json.JSONObject; import org.w3c.dom.Document; @@ -1058,6 +1059,9 @@ private void bundleJdeploy(PackagingContext context) throws IOException { private String processJdeployTemplate(PackagingContext context, String jdeployContents) { jdeployContents = jdeployContents.replace("{{JAVA_VERSION}}", String.valueOf(context.getJavaVersion(DEFAULT_JAVA_VERSION))); jdeployContents = jdeployContents.replace("{{PORT}}", String.valueOf(context.getPort(0))); + // Inject the "jdeploy.args" array as a JSON literal so the launcher can + // apply publisher-declared JVM/program args (e.g. --add-opens) at runtime. + jdeployContents = jdeployContents.replace("{{JAVA_ARGS}}", toJavaArgsJson(context.getList("args", true))); if (context.getWar(null) != null) { jdeployContents = jdeployContents.replace("{{WAR_PATH}}", new File(context.getWar(null)).getName()); } else { @@ -1085,6 +1089,23 @@ private String processJdeployTemplate(PackagingContext context, String jdeployCo return jdeployContents; } + /** + * Serializes the "jdeploy.args" list into a JSON array literal for injection + * into the bundled jdeploy.js launcher. Non-string entries are coerced to + * their string form; a null or empty list yields "[]". + */ + private String toJavaArgsJson(List args) { + JSONArray out = new JSONArray(); + if (args != null) { + for (Object arg : args) { + if (arg != null) { + out.put(String.valueOf(arg)); + } + } + } + return out.toString(); + } + private void bundleJetty(PackagingContext context) throws IOException { // Now we need to create the stub. File bin = context.getJdeployBundleDir(); diff --git a/cli/src/main/resources/ca/weblite/jdeploy/jdeploy.js b/cli/src/main/resources/ca/weblite/jdeploy/jdeploy.js index c1c45ce7..f6d9c81b 100644 --- a/cli/src/main/resources/ca/weblite/jdeploy/jdeploy.js +++ b/cli/src/main/resources/ca/weblite/jdeploy/jdeploy.js @@ -8,6 +8,12 @@ var mainClass = "{{MAIN_CLASS}}"; var classPath = "{{CLASSPATH}}"; var port = "{{PORT}}"; var warPath = "{{WAR_PATH}}"; +// JVM/program arguments declared in the "jdeploy.args" array of package.json. +// Injected at publish time as a JSON array literal (defaults to []). These are +// processed at runtime (see processPackageArg/appendPackageArgs) so that +// platform-conditional args such as "-[mac]--add-opens ..." resolve on the +// machine the app actually runs on, mirroring the client4j launcher. +var packageArgs = {{JAVA_ARGS}}; var javaVersionString = "{{JAVA_VERSION}}"; var tryJavaHomeFirst = false; var javafx = false; @@ -584,6 +590,112 @@ if (!done) { +// Resolves the platform-conditional prefix syntax used in package.json +// "jdeploy.args" entries, mirroring processArg() in the client4j launcher. +// +// Supported prefixes (the bracket lists a comma-separated set of conditions +// that must ALL be satisfied; a condition may itself be a pipe-separated OR +// group such as "mac|linux"): +// -[conditions] generic, e.g. "-[mac]--add-opens java.desktop/...=..." +// -D[conditions] becomes "-D" when the conditions pass +// -X[conditions] becomes "-X" when the conditions pass +// +// Returns the resolved argument string, or '' if the conditions exclude the +// current platform (in which case the caller drops the argument). +function processPackageArg(arg) { + var isMac = (process.platform === 'darwin'); + var isWin = (process.platform === 'win32'); + var isLinux = (process.platform === 'linux'); + + if ((arg.indexOf('-D[') === 0 || arg.indexOf('-X[') === 0 || arg.indexOf('-[') === 0) && arg.indexOf(']') >= 0) { + var conditionsStr = arg.substring(arg.indexOf('[') + 1, arg.indexOf(']')); + var conditions = conditionsStr.split(','); + var fulfilled = true; + for (var i = 0; i < conditions.length; i++) { + var condition = conditions[i].trim(); + var lcCondition = condition.toLowerCase(); + if (lcCondition === 'mac' && !isMac) { + fulfilled = false; + continue; + } else if (lcCondition === 'win' && !isWin) { + fulfilled = false; + continue; + } else if (lcCondition === 'linux' && !isLinux) { + fulfilled = false; + continue; + } else if (lcCondition === 'windows' && !isWin) { + fulfilled = false; + continue; + } + + if (condition.indexOf('|') >= 0) { + lcCondition = '|' + lcCondition + '|'; + fulfilled = false; + if (isMac && lcCondition.indexOf('|mac|') >= 0) { + fulfilled = true; + } else if (isLinux && lcCondition.indexOf('|linux|') >= 0) { + fulfilled = true; + } else if (isWin && lcCondition.indexOf('|win|') >= 0) { + fulfilled = true; + } else if (isWin && lcCondition.indexOf('|windows|') >= 0) { + fulfilled = true; + } + } + } + + if (!fulfilled) { + return ''; + } + if (arg.indexOf('-[') === 0) { + arg = arg.substring(arg.indexOf(']') + 1); + } else if (arg.indexOf('-D[') === 0) { + arg = '-D' + arg.substring(arg.indexOf(']') + 1); + } else if (arg.indexOf('-X[') === 0) { + arg = '-X' + arg.substring(arg.indexOf(']') + 1); + } + } + + return arg; +} + +// Categorizes each package.json "jdeploy.args" entry into JVM args (placed +// before -jar) or program args (placed after the jar), mirroring the relevant +// part of processRunArgs() in the client4j launcher. A "--flag value" JVM +// option (e.g. "--add-opens java.desktop/com.apple.eawt=ALL-UNNAMED") is split +// into two tokens because the JVM expects the flag and value as separate +// arguments. Package args are appended before the user-supplied CLI args. +function appendPackageArgs(rawArgs, javaArgs, programArgs) { + if (!Array.isArray(rawArgs)) { + return; + } + rawArgs.forEach(function(rawArg) { + var arg = processPackageArg(rawArg); + if (arg === '' || arg === null || typeof arg === 'undefined') { + return; + } + if (arg.indexOf('-D') === 0 || arg.indexOf('-X') === 0) { + javaArgs.push(arg); + } else if (arg.indexOf('--') === 0) { + // JVM module/access options (--add-opens, --add-exports, + // --add-modules, --module-path, --enable-preview, ...). Split off a + // value if one is present in the same token. + var spaceIdx = arg.indexOf(' '); + if (spaceIdx >= 0) { + javaArgs.push(arg.substring(0, spaceIdx)); + javaArgs.push(arg.substring(spaceIdx + 1)); + } else { + javaArgs.push(arg); + } + } else if (arg.indexOf('-p ') === 0) { + // Short form of --module-path. + javaArgs.push('-p'); + javaArgs.push(arg.substring(3)); + } else { + programArgs.push(arg); + } + }); +} + function run(_javaHome) { var fail = reason => { console.error(reason); @@ -608,6 +720,8 @@ function run(_javaHome) { javaArgs.push('-Djdeploy.port='+port); javaArgs.push('-Djdeploy.war.path='+warPath); var programArgs = []; + // Args declared in package.json (jdeploy.args) come before the user's CLI args. + appendPackageArgs(packageArgs, javaArgs, programArgs); userArgs.forEach(function(arg) { if (arg.startsWith('-D') || arg.startsWith('-X')) { javaArgs.push(arg); diff --git a/cli/src/test/java/ca/weblite/jdeploy/cli/nodelauncher/JDeployIntegrationTest.java b/cli/src/test/java/ca/weblite/jdeploy/cli/nodelauncher/JDeployIntegrationTest.java index 065dbabb..5bf6fb6a 100644 --- a/cli/src/test/java/ca/weblite/jdeploy/cli/nodelauncher/JDeployIntegrationTest.java +++ b/cli/src/test/java/ca/weblite/jdeploy/cli/nodelauncher/JDeployIntegrationTest.java @@ -64,7 +64,8 @@ void testJDeployScript() throws Exception { .replace("{{MAIN_CLASS}}", JAVA_CLASS) .replace("{{JAVA_VERSION}}", "17") // Using JDK 17 .replace("{{JAVAFX}}", "false") - .replace("{{JDK}}", "false"); + .replace("{{JDK}}", "false") + .replace("{{JAVA_ARGS}}", "[]"); // No package.json jdeploy.args in this test System.out.println("DEBUG: Placeholders replaced successfully"); // Step 3: Save the modified script to a temp file inside jdeploy-bundle diff --git a/tests/jdeploy-js/package-args.test.js b/tests/jdeploy-js/package-args.test.js new file mode 100644 index 00000000..0cd1e9ca --- /dev/null +++ b/tests/jdeploy-js/package-args.test.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/* + * Regression test for package.json "jdeploy.args" support in the generated + * launcher (cli/.../jdeploy.js). + * + * The launcher must honour JVM/program args declared in package.json's + * jdeploy.args array, including the platform-conditional prefix syntax + * ("-[mac]...", "-D[win]...", "-X[linux]...") and the "--flag value" splitting + * that the JVM requires for options like --add-opens. This mirrors the + * processArg()/processRunArgs() behaviour of the client4j launcher. + * + * The test exercises the REAL processPackageArg/appendPackageArgs functions + * lifted straight out of jdeploy.js (by brace matching) so it tracks the + * shipped implementation rather than a copy of it. process.platform is + * temporarily overridden to exercise each platform branch on any host. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const JDEPLOY_JS = path.resolve( + __dirname, + '..', + '..', + 'cli', + 'src', + 'main', + 'resources', + 'ca', + 'weblite', + 'jdeploy', + 'jdeploy.js' +); + +function extractFunctionSource(source, name) { + const marker = 'function ' + name; + const start = source.indexOf(marker); + if (start < 0) { + throw new Error('Could not find `' + marker + '` in ' + JDEPLOY_JS); + } + let depth = 0; + let started = false; + for (let i = source.indexOf('{', start); i < source.length; i++) { + const ch = source[i]; + if (ch === '{') { + depth++; + started = true; + } else if (ch === '}') { + depth--; + if (started && depth === 0) { + return source.slice(start, i + 1); + } + } + } + throw new Error('Unbalanced braces while extracting `' + name + '`'); +} + +function loadArgFns() { + const source = fs.readFileSync(JDEPLOY_JS, 'utf8'); + const body = + extractFunctionSource(source, 'processPackageArg') + + '\n' + + extractFunctionSource(source, 'appendPackageArgs') + + '\nreturn { processPackageArg: processPackageArg, appendPackageArgs: appendPackageArgs };'; + // Both functions only depend on the global `process` and `Array`. + return new Function(body)(); +} + +// Runs `fn` with process.platform pinned to `platform`, then restores it. +function withPlatform(platform, fn) { + const original = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + return fn(); + } finally { + Object.defineProperty(process, 'platform', original); + } +} + +function categorize(fns, rawArgs, platform) { + return withPlatform(platform, function () { + const javaArgs = []; + const programArgs = []; + fns.appendPackageArgs(rawArgs, javaArgs, programArgs); + return { javaArgs: javaArgs, programArgs: programArgs }; + }); +} + +function main() { + const fns = loadArgFns(); + + // --- The motivating case: mac-only --add-opens is split into two JVM args --- + const macAddOpens = ['-[mac]--add-opens java.desktop/com.apple.eawt=ALL-UNNAMED']; + + assert.deepStrictEqual( + categorize(fns, macAddOpens, 'darwin'), + { + javaArgs: ['--add-opens', 'java.desktop/com.apple.eawt=ALL-UNNAMED'], + programArgs: [], + }, + 'mac --add-opens should be split into flag + value on macOS' + ); + + assert.deepStrictEqual( + categorize(fns, macAddOpens, 'win32'), + { javaArgs: [], programArgs: [] }, + 'mac-only arg should be dropped on Windows' + ); + assert.deepStrictEqual( + categorize(fns, macAddOpens, 'linux'), + { javaArgs: [], programArgs: [] }, + 'mac-only arg should be dropped on Linux' + ); + + // --- Unconditional --add-opens is always split, on every platform --- + const bareAddOpens = ['--add-opens java.base/java.lang=ALL-UNNAMED']; + ['darwin', 'win32', 'linux'].forEach(function (p) { + assert.deepStrictEqual( + categorize(fns, bareAddOpens, p), + { + javaArgs: ['--add-opens', 'java.base/java.lang=ALL-UNNAMED'], + programArgs: [], + }, + 'unconditional --add-opens should be split on ' + p + ); + }); + + // --- -D[win] / -X[linux] prefixes rewrite to -D.../-X... when they apply --- + assert.deepStrictEqual( + categorize(fns, ['-D[win]foo=bar'], 'win32'), + { javaArgs: ['-Dfoo=bar'], programArgs: [] }, + '-D[win] should become -Dfoo=bar on Windows' + ); + assert.deepStrictEqual( + categorize(fns, ['-D[win]foo=bar'], 'linux'), + { javaArgs: [], programArgs: [] }, + '-D[win] should be dropped off Windows' + ); + assert.deepStrictEqual( + categorize(fns, ['-X[linux]mx512m'], 'linux'), + { javaArgs: ['-Xmx512m'], programArgs: [] }, + '-X[linux]mx512m should become -Xmx512m on Linux' + ); + + // --- Pipe-OR conditions: mac|linux applies on both, not Windows --- + const orArg = ['-[mac|linux]--add-exports java.base/sun.nio.ch=ALL-UNNAMED']; + assert.deepStrictEqual( + categorize(fns, orArg, 'darwin').javaArgs, + ['--add-exports', 'java.base/sun.nio.ch=ALL-UNNAMED'], + 'mac|linux applies on macOS' + ); + assert.deepStrictEqual( + categorize(fns, orArg, 'linux').javaArgs, + ['--add-exports', 'java.base/sun.nio.ch=ALL-UNNAMED'], + 'mac|linux applies on Linux' + ); + assert.deepStrictEqual( + categorize(fns, orArg, 'win32'), + { javaArgs: [], programArgs: [] }, + 'mac|linux is dropped on Windows' + ); + + // --- Plain -D / -X pass through untouched; program args go after the jar --- + assert.deepStrictEqual( + categorize(fns, ['-Dfoo=bar', '-Xmx1g', 'myProgramArg'], 'linux'), + { javaArgs: ['-Dfoo=bar', '-Xmx1g'], programArgs: ['myProgramArg'] }, + 'plain -D/-X are JVM args; non-dash tokens are program args' + ); + + // --- Value-less double-dash flags are kept as a single JVM token --- + assert.deepStrictEqual( + categorize(fns, ['--enable-preview'], 'linux'), + { javaArgs: ['--enable-preview'], programArgs: [] }, + 'value-less --enable-preview stays a single token' + ); + + // --- Short module-path form -p is split into -p + value --- + assert.deepStrictEqual( + categorize(fns, ['-p /some/modules'], 'linux'), + { javaArgs: ['-p', '/some/modules'], programArgs: [] }, + '-p is split into two JVM tokens' + ); + + // --- Non-array / empty inputs are tolerated --- + assert.deepStrictEqual( + categorize(fns, [], 'linux'), + { javaArgs: [], programArgs: [] }, + 'empty args produce no output' + ); + (function () { + const javaArgs = []; + const programArgs = []; + fns.appendPackageArgs(undefined, javaArgs, programArgs); + assert.deepStrictEqual({ javaArgs, programArgs }, { javaArgs: [], programArgs: [] }, + 'undefined args are tolerated'); + })(); + + console.log('package-args test passed'); +} + +main(); diff --git a/tests/jdeploy-js/test.sh b/tests/jdeploy-js/test.sh index ee0e02c0..289f0c86 100644 --- a/tests/jdeploy-js/test.sh +++ b/tests/jdeploy-js/test.sh @@ -4,19 +4,24 @@ set -e SCRIPTPATH="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" cd "$SCRIPTPATH" +if ! command -v node >/dev/null 2>&1; then + echo "Skipping jdeploy.js launcher tests: node is not installed." + exit 0 +fi + +# The package-args test has no native dependencies and is platform-independent +# (it mocks process.platform), so run it everywhere before any early exit. +echo "Running jdeploy.js package-args test..." +node package-args.test.js + # Windows has no Unix execute bit, so the zip-mode test does not apply there. case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) - echo "Skipping jdeploy.js launcher tests on Windows." + echo "Skipping jdeploy.js zip-mode test on Windows." exit 0 ;; esac -if ! command -v node >/dev/null 2>&1; then - echo "Skipping jdeploy.js launcher tests: node is not installed." - exit 0 -fi - echo "Installing jdeploy.js test dependencies..." npm install --no-audit --no-fund --silent