diff --git a/.github/workflows/e2e-npm-link-tests.yml b/.github/workflows/e2e-npm-link-tests.yml new file mode 100644 index 00000000..46ecb798 --- /dev/null +++ b/.github/workflows/e2e-npm-link-tests.yml @@ -0,0 +1,83 @@ +name: E2E NPM Link Tests + +on: + pull_request: + branches: [ master ] + workflow_dispatch: + +jobs: + e2e-npm-link-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Configure npm prefix to user-writable dir + run: | + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" + echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" + + - name: Build jdeploy + run: mvn clean install -DskipTests -q + + - name: Run npm-link install E2E test + run: | + chmod +x tests/e2e/e2e-npm-link-test.sh + tests/e2e/e2e-npm-link-test.sh --verbose + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-npm-link-results-linux + path: tests/e2e/results-npm-link/ + + e2e-npm-link-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Configure npm prefix to user-writable dir + run: | + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" + echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" + + - name: Build jdeploy + run: mvn clean install -DskipTests -q + + - name: Run npm-link install E2E test + run: | + chmod +x tests/e2e/e2e-npm-link-test.sh + tests/e2e/e2e-npm-link-test.sh --verbose + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-npm-link-results-macos + path: tests/e2e/results-npm-link/ diff --git a/cli/README.md b/cli/README.md index 2f7cfbff..9ec34891 100644 --- a/cli/README.md +++ b/cli/README.md @@ -51,12 +51,16 @@ This will generate a package.json file with settings to allow you to publish the $ jdeploy install ~~~~ -This performs a full native installation with native launchers, CLI commands, etc. +This uses `npm link` to install the app's CLI commands locally so the commands +declared in `package.json` are available on your `$PATH`. This is the fastest +way to dogfood the published install behavior of an npm-distributed CLI. -To use legacy npm link behavior instead: +For a full native installation with native launchers, GUI integration, and +service registration (the same flow used to test desktop apps before +publishing): ~~~~ -$ jdeploy install --npm +$ jdeploy install --native ~~~~ **Publish App to NPM** diff --git a/cli/src/main/java/ca/weblite/jdeploy/JDeploy.java b/cli/src/main/java/ca/weblite/jdeploy/JDeploy.java index 75d723a9..e17113d7 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/JDeploy.java +++ b/cli/src/main/java/ca/weblite/jdeploy/JDeploy.java @@ -561,9 +561,9 @@ private void help(Options opts) { + "Commands:\n" + " init : Initialize the project\n" + " package : Prepare for install. This copies necessary files into bin directory.\n" - + " install : Installs the app locally with native launchers, CLI commands, etc.\n" - + " install --ai-tools=claude-code,cursor : Also configure MCP server for specified AI tools\n" - + " install --npm : Use npm link instead of native installation (legacy behavior)\n" + + " install : Installs the app locally using npm link (so its CLI commands are on $PATH)\n" + + " install --native : Full native install (native launchers, GUI integration, services, AI tool MCP, etc.)\n" + + " install --native --ai-tools=claude-code,cursor : Native install plus configure MCP server for specified AI tools\n" + " uninstall : Uninstalls the app locally\n" + " uninstall --package= --source= : Uninstall by package name\n" + " run : Launch the installed GUI application\n" @@ -675,12 +675,15 @@ private InstallationVerificationResult resolveAndVerifyUninstallation( } private void _run(PackagingContext context, String commandName, String[] commandArgs, - boolean installFirst, boolean npmInstallFlag, + boolean installFirst, java.util.Set aiTools) { try { if (installFirst) { out.println("Running install before run..."); - install(context, npmInstallFlag, aiTools); + // run/debug rely on the native install layout located by + // LocalRunService, so install --install always uses the + // headless installer (never npm link). + install(context, false, aiTools); } LocalRunService runService = DIContext.get(LocalRunService.class); @@ -708,12 +711,13 @@ private void _run(PackagingContext context, String commandName, String[] command } private void _debug(PackagingContext context, String commandName, String[] commandArgs, - int port, boolean suspend, boolean installFirst, boolean npmInstallFlag, + int port, boolean suspend, boolean installFirst, java.util.Set aiTools) { try { if (installFirst) { out.println("Running install before debug..."); - install(context, npmInstallFlag, aiTools); + // See _run: native install is required for LocalRunService. + install(context, false, aiTools); } LocalRunService runService = DIContext.get(LocalRunService.class); @@ -904,7 +908,8 @@ public static void main(String[] args) { opts.addOption("t", "tag", true, "Optional tag for publish."); opts.addOption("y", "no-prompt", false,"Indicates not to prompt_ user "); opts.addOption("W", "no-workflow", false,"Indicates not to create a github workflow if true"); - opts.addOption("N", "npm", false, "Use npm link instead of native installation (legacy behavior)"); + opts.addOption("N", "npm", false, "Use npm link to install the app's CLI commands (default behavior, kept for back-compat)"); + opts.addOption(null, "native", false, "Use the full native install (headless installer with native launchers, GUI integration, services, etc.) instead of npm link"); opts.addOption("A", "ai-tools", true, "Comma-separated list of AI tools to configure for MCP server (e.g., claude-code,cursor,claude-desktop)"); opts.addOption("p", "port", true, "Debug port for 'jdeploy debug' command (default: 5005)"); opts.addOption("S", "no-suspend", false, "Don't wait for debugger to attach (default: wait)"); @@ -928,7 +933,9 @@ public static void main(String[] args) { .build()); boolean noPromptFlag = false; boolean noWorkflowFlag = false; - boolean npmInstallFlag = false; + // jdeploy install: default to npm link. --native opts into the + // headless installer flow used for local GUI testing. + boolean npmInstallFlag = true; boolean runInstallFirst = false; String distTag = null; int debugPort = LocalRunService.getDefaultDebugPort(); @@ -967,7 +974,11 @@ public static void main(String[] args) { args = line.getArgs(); noPromptFlag = line.hasOption("no-prompt"); noWorkflowFlag = line.hasOption("no-workflow"); - npmInstallFlag = line.hasOption("npm"); + if (line.hasOption("native")) { + npmInstallFlag = false; + } else if (line.hasOption("npm")) { + npmInstallFlag = true; + } distTag = line.getOptionValue("tag", null); String aiToolsStr = line.getOptionValue("ai-tools", null); if (aiToolsStr != null && !aiToolsStr.isEmpty()) { @@ -1140,11 +1151,11 @@ public static void main(String[] args) { prog.runOnLinux(context, linuxMode, commandArgs); } else { String commandName = args.length > 1 ? args[1] : null; - prog._run(context, commandName, commandArgs, runInstallFirst, npmInstallFlag, aiTools); + prog._run(context, commandName, commandArgs, runInstallFirst, aiTools); } } else if ("debug".equals(args[0])) { String commandName = args.length > 1 ? args[1] : null; - prog._debug(context, commandName, commandArgs, debugPort, debugSuspend, runInstallFirst, npmInstallFlag, aiTools); + prog._debug(context, commandName, commandArgs, debugPort, debugSuspend, runInstallFirst, aiTools); } else if ("help".equals(args[0])) { prog.help(opts); } else if ("verify-installation".equals(args[0])) { 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 e368ecb4..4edc4046 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java +++ b/cli/src/main/java/ca/weblite/jdeploy/packaging/PackageService.java @@ -10,6 +10,7 @@ import ca.weblite.jdeploy.appbundler.*; import ca.weblite.jdeploy.appbundler.mac.DmgCreator; import ca.weblite.jdeploy.environment.Environment; +import ca.weblite.jdeploy.helpers.NpmPackageUtils; import ca.weblite.jdeploy.helpers.PrereleaseHelper; import ca.weblite.jdeploy.services.BundleCodeService; import ca.weblite.jdeploy.services.ProjectBuilderService; @@ -879,7 +880,10 @@ private void loadAppInfo(PackagingContext context, AppInfo appInfo) throws IOExc appInfo.setTitle( context.getString( "displayName", - context.getString("title", appInfo.getNpmPackage()) + context.getString( + "title", + NpmPackageUtils.deriveDefaultTitle(appInfo.getNpmPackage()) + ) ) ); diff --git a/cli/src/main/java/ca/weblite/jdeploy/services/LocalJDeployFilesGenerator.java b/cli/src/main/java/ca/weblite/jdeploy/services/LocalJDeployFilesGenerator.java index d86144ef..dc432530 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/services/LocalJDeployFilesGenerator.java +++ b/cli/src/main/java/ca/weblite/jdeploy/services/LocalJDeployFilesGenerator.java @@ -1,6 +1,7 @@ package ca.weblite.jdeploy.services; import ca.weblite.jdeploy.JDeploy; +import ca.weblite.jdeploy.helpers.NpmPackageUtils; import org.apache.commons.io.FileUtils; import org.json.JSONObject; @@ -182,7 +183,7 @@ private String xmlEscape(String text) { /** * Gets the application title from package.json. - * Priority: jdeploy.title > name + * Priority: jdeploy.title > displayName > name (with npm scope stripped) */ private String getTitle(JSONObject packageJson) { if (packageJson.has("jdeploy")) { @@ -191,7 +192,10 @@ private String getTitle(JSONObject packageJson) { return jdeploy.getString("title"); } } - return packageJson.getString("name"); + if (packageJson.has("displayName")) { + return packageJson.getString("displayName"); + } + return NpmPackageUtils.deriveDefaultTitle(packageJson.getString("name")); } /** diff --git a/cli/src/main/java/ca/weblite/jdeploy/services/PublishBundleService.java b/cli/src/main/java/ca/weblite/jdeploy/services/PublishBundleService.java index 01fc5099..f6d3532f 100644 --- a/cli/src/main/java/ca/weblite/jdeploy/services/PublishBundleService.java +++ b/cli/src/main/java/ca/weblite/jdeploy/services/PublishBundleService.java @@ -4,6 +4,7 @@ import ca.weblite.jdeploy.appbundler.BundlerSettings; import ca.weblite.jdeploy.appbundler.Bundler; import ca.weblite.jdeploy.app.AppInfo; +import ca.weblite.jdeploy.helpers.NpmPackageUtils; import ca.weblite.jdeploy.installer.util.CliCommandBinDirResolver; import ca.weblite.jdeploy.models.BundleArtifact; import ca.weblite.jdeploy.models.BundleManifest; @@ -236,7 +237,10 @@ private void loadAppInfo(PackagingContext context, AppInfo appInfo) throws IOExc appInfo.setMacAppBundleId(context.getString("macAppBundleId", null)); appInfo.setTitle( context.getString("displayName", - context.getString("title", appInfo.getNpmPackage()) + context.getString( + "title", + NpmPackageUtils.deriveDefaultTitle(appInfo.getNpmPackage()) + ) ) ); diff --git a/cli/src/main/resources/scripts/linux/dev-install-and-launch.sh b/cli/src/main/resources/scripts/linux/dev-install-and-launch.sh index bf7862d4..e9075f86 100644 --- a/cli/src/main/resources/scripts/linux/dev-install-and-launch.sh +++ b/cli/src/main/resources/scripts/linux/dev-install-and-launch.sh @@ -109,9 +109,10 @@ cd "$APP_DIR" # Ensure DISPLAY is set for GUI apps export DISPLAY=:1 -# Run jdeploy install first -log "Running: java -jar $JDEPLOY_JAR install" -java -jar "$JDEPLOY_JAR" install 2>&1 | tee -a "$LOG_FILE" +# Run jdeploy install first. +# --native is required so 'jdeploy run' below can find the launcher. +log "Running: java -jar $JDEPLOY_JAR install --native" +java -jar "$JDEPLOY_JAR" install --native 2>&1 | tee -a "$LOG_FILE" log "Installation complete. Now launching the app..." diff --git a/cli/src/main/resources/scripts/linux/install-and-launch.sh b/cli/src/main/resources/scripts/linux/install-and-launch.sh index 12322d85..1f32ab85 100644 --- a/cli/src/main/resources/scripts/linux/install-and-launch.sh +++ b/cli/src/main/resources/scripts/linux/install-and-launch.sh @@ -60,9 +60,11 @@ cd "$APP_DIR" # Ensure DISPLAY is set for GUI apps export DISPLAY=:1 -# Run jdeploy install first -log "Running: jdeploy install" -jdeploy install 2>&1 | tee -a "$LOG_FILE" +# Run jdeploy install first. +# --native is required so that the headless installer produces the +# native launcher layout that 'jdeploy run' below expects. +log "Running: jdeploy install --native" +jdeploy install --native 2>&1 | tee -a "$LOG_FILE" log "Installation complete. Now launching the app..." diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java index b1b2098d..badf7c84 100644 --- a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java +++ b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java @@ -4,6 +4,7 @@ import ca.weblite.jdeploy.app.permissions.PermissionRequest; import ca.weblite.jdeploy.appbundler.Bundler; import ca.weblite.jdeploy.appbundler.BundlerSettings; +import ca.weblite.jdeploy.helpers.NpmPackageUtils; import ca.weblite.jdeploy.helpers.PrereleaseHelper; import ca.weblite.jdeploy.installer.events.InstallationFormEvent; import ca.weblite.jdeploy.installer.events.InstallationFormEventDispatcher; @@ -451,7 +452,11 @@ private void loadAppInfo() throws IOException { appInfo(new AppInfo()); appInfo().setJdeployRegistryUrl(JDEPLOY_REGISTRY_URL); appInfo().setAppURL(appXml.toURI().toURL()); - appInfo().setTitle(ifEmpty(root.getAttribute("title"), root.getAttribute("package"), null)); + appInfo().setTitle(ifEmpty( + root.getAttribute("title"), + NpmPackageUtils.deriveDefaultTitle(root.getAttribute("package")), + null + )); appInfo().setNpmPackage(ifEmpty(root.getAttribute("package"), null)); String fullyQualifiedPackageName = appInfo().getNpmPackage(); if (root.hasAttribute("source")) { diff --git a/shared/src/main/java/ca/weblite/jdeploy/helpers/NpmPackageUtils.java b/shared/src/main/java/ca/weblite/jdeploy/helpers/NpmPackageUtils.java new file mode 100644 index 00000000..d99cc15a --- /dev/null +++ b/shared/src/main/java/ca/weblite/jdeploy/helpers/NpmPackageUtils.java @@ -0,0 +1,34 @@ +package ca.weblite.jdeploy.helpers; + +/** + * Utilities for working with npm package names. + */ +public final class NpmPackageUtils { + + private NpmPackageUtils() { + } + + /** + * Derives a default title from an npm package name. For scoped packages + * (e.g. {@code @scope/name}), the {@code @scope/} prefix is stripped so the + * resulting title can safely be used as a file or directory name without + * the slash being interpreted as a path separator. + * + * @param npmPackage The npm package name. May be {@code null}. + * @return The package name with any leading {@code @scope/} prefix removed, + * or the original value if it is not a scoped package or is + * {@code null}. + */ + public static String deriveDefaultTitle(String npmPackage) { + if (npmPackage == null) { + return null; + } + if (npmPackage.startsWith("@")) { + int slash = npmPackage.indexOf('/'); + if (slash >= 0 && slash < npmPackage.length() - 1) { + return npmPackage.substring(slash + 1); + } + } + return npmPackage; + } +} diff --git a/shared/src/test/java/ca/weblite/jdeploy/helpers/NpmPackageUtilsTest.java b/shared/src/test/java/ca/weblite/jdeploy/helpers/NpmPackageUtilsTest.java new file mode 100644 index 00000000..baaf2739 --- /dev/null +++ b/shared/src/test/java/ca/weblite/jdeploy/helpers/NpmPackageUtilsTest.java @@ -0,0 +1,34 @@ +package ca.weblite.jdeploy.helpers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class NpmPackageUtilsTest { + + @Test + public void scopedPackageNameStripsScope() { + assertEquals("cli", NpmPackageUtils.deriveDefaultTitle("@crowdin/cli")); + } + + @Test + public void unscopedPackageNameUnchanged() { + assertEquals("my-app", NpmPackageUtils.deriveDefaultTitle("my-app")); + } + + @Test + public void nullReturnsNull() { + assertNull(NpmPackageUtils.deriveDefaultTitle(null)); + } + + @Test + public void scopeWithoutSlashUnchanged() { + assertEquals("@scope", NpmPackageUtils.deriveDefaultTitle("@scope")); + } + + @Test + public void trailingSlashUnchanged() { + assertEquals("@scope/", NpmPackageUtils.deriveDefaultTitle("@scope/")); + } +} diff --git a/tests/e2e/e2e-local-test.ps1 b/tests/e2e/e2e-local-test.ps1 index 45b96387..da01bbc5 100644 --- a/tests/e2e/e2e-local-test.ps1 +++ b/tests/e2e/e2e-local-test.ps1 @@ -214,7 +214,9 @@ function Install-Project { Push-Location $ProjectDir try { - $output = Invoke-Jdeploy -Arguments @("install", "-y") + # --native: this test exercises the full headless install flow + # (verify-installation below relies on its on-disk layout). + $output = Invoke-Jdeploy -Arguments @("install", "--native", "-y") $output | Out-File -FilePath $projectLog -Append if ($LASTEXITCODE -eq 0) { diff --git a/tests/e2e/e2e-local-test.sh b/tests/e2e/e2e-local-test.sh index 665fcf96..8d8dd314 100755 --- a/tests/e2e/e2e-local-test.sh +++ b/tests/e2e/e2e-local-test.sh @@ -207,7 +207,9 @@ install_project() { cd "$project_dir" - if run_jdeploy install -y 2>&1 | tee -a "$project_log"; then + # --native: this test exercises the full headless install flow + # (verify-installation below relies on its on-disk layout). + if run_jdeploy install --native -y 2>&1 | tee -a "$project_log"; then log "Project installed successfully" cd "$SCRIPT_DIR" return 0 diff --git a/tests/e2e/e2e-npm-link-test.sh b/tests/e2e/e2e-npm-link-test.sh new file mode 100755 index 00000000..4983ab4b --- /dev/null +++ b/tests/e2e/e2e-npm-link-test.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# e2e-npm-link-test.sh +# End-to-end test for the default `jdeploy install` (npm link) flow. +# +# Generates a picocli project, runs `jdeploy install` (default = npm link), +# and verifies that the project's bin command is on $PATH and runnable. +# +# Usage: ./e2e-npm-link-test.sh [OPTIONS] +# --verbose Show verbose output +# --keep-projects Don't delete the generated test project +# --help Show this help +# +# Exit codes: +# 0 - Test passed +# 1 - Test failed +# 2 - Configuration error + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +VERBOSE=false +KEEP_PROJECTS=false +RESULTS_DIR="${SCRIPT_DIR}/results-npm-link" +TIMESTAMP=$(date '+%Y%m%d_%H%M%S') +LOG_FILE="${RESULTS_DIR}/e2e-npm-link-test-${TIMESTAMP}.log" +TEST_PROJECTS_DIR="${SCRIPT_DIR}/test-projects-npm-link" + +for arg in "$@"; do + case $arg in + --verbose) VERBOSE=true ;; + --keep-projects) KEEP_PROJECTS=true ;; + --help|-h) + grep '^#' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) + echo "Unknown option: $arg" + exit 2 + ;; + esac +done + +mkdir -p "$RESULTS_DIR" "$TEST_PROJECTS_DIR" + +log() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$timestamp] $1" | tee -a "$LOG_FILE" +} + +log_verbose() { [ "$VERBOSE" = true ] && log "$1" || true; } + +# Locate jdeploy CLI jar built from source. +JDEPLOY_JAR="$PROJECT_ROOT/cli/target/jdeploy-cli-1.0-SNAPSHOT.jar" +if [ ! -f "$JDEPLOY_JAR" ]; then + log "ERROR: jdeploy CLI JAR not found at $JDEPLOY_JAR" + log "Please run 'mvn clean install' from the project root first." + exit 2 +fi +log "jdeploy CLI: $JDEPLOY_JAR" + +run_jdeploy() { java -jar "$JDEPLOY_JAR" "$@"; } + +if ! command -v java >/dev/null 2>&1; then + log "ERROR: java is not installed" + exit 2 +fi +if ! command -v mvn >/dev/null 2>&1; then + log "ERROR: maven is not installed (required to build the picocli template)" + exit 2 +fi +if ! command -v npm >/dev/null 2>&1; then + log "ERROR: npm is not installed" + exit 2 +fi + +PROJECT_NAME="test-npm-link-${TIMESTAMP}" +PROJECT_DIR="${TEST_PROJECTS_DIR}/${PROJECT_NAME}" +INSTALL_LOG="${RESULTS_DIR}/install.log" + +cleanup() { + log "Cleaning up..." + # npm unlink to remove the global symlink we created. + if [ -d "$PROJECT_DIR" ]; then + (cd "$PROJECT_DIR" && npm unlink -g 2>/dev/null || true) + fi + if [ "$KEEP_PROJECTS" = false ] && [ -d "$PROJECT_DIR" ]; then + rm -rf "$PROJECT_DIR" + fi +} +trap cleanup EXIT + +log "==========================================" +log "jDeploy npm-link install E2E test" +log "==========================================" + +log "Generating picocli project: $PROJECT_NAME" +run_jdeploy generate \ + -t picocli \ + -d "$TEST_PROJECTS_DIR" \ + -n "$PROJECT_NAME" \ + --appTitle="NPM Link Test" \ + -g com.test.npmlink \ + -a "$PROJECT_NAME" \ + --mainClassName=Main 2>&1 | tee -a "$LOG_FILE" + +cd "$PROJECT_DIR" + +log "Building project (mvn clean package)..." +mvn clean package -q 2>&1 | tee -a "$LOG_FILE" + +# Read the bin command name from package.json. The picocli template registers +# the command name passed to --appTitle, lowercased / hyphenated. +BIN_NAME=$(python3 -c "import json,sys; print(list(json.load(open('package.json'))['bin'].keys())[0])" 2>/dev/null || \ + node -e "console.log(Object.keys(require('./package.json').bin)[0])") +if [ -z "$BIN_NAME" ]; then + log "ERROR: could not read bin command name from package.json" + exit 1 +fi +log "Bin command: $BIN_NAME" + +# Sanity: the command should NOT exist before install (avoid confusing a +# residual symlink on the developer's machine for a successful install). +if command -v "$BIN_NAME" >/dev/null 2>&1; then + log "WARNING: '$BIN_NAME' was already on \$PATH before install, at $(command -v "$BIN_NAME")" + log " Test will only verify that it remains on \$PATH after install." +fi + +log "Running: jdeploy install (default = npm link)..." +run_jdeploy install 2>&1 | tee -a "$INSTALL_LOG" | tee -a "$LOG_FILE" + +log "Verifying bin command is on \$PATH..." +if ! command -v "$BIN_NAME" >/dev/null 2>&1; then + log "FAIL: '$BIN_NAME' is not on \$PATH after 'jdeploy install'" + log "npm global prefix: $(npm prefix -g 2>/dev/null || echo unknown)" + log "npm global bin contents:" + ls -la "$(npm prefix -g 2>/dev/null)/bin" 2>&1 | tee -a "$LOG_FILE" || true + exit 1 +fi +log "OK: '$BIN_NAME' resolves to $(command -v "$BIN_NAME")" + +log "Running '$BIN_NAME --help' to verify it executes..." +# Capture output and exit status without aborting on non-zero — picocli +# templates may legitimately return non-zero for --help (e.g. exit 2 when +# usage is treated as an error). The thing this test really cares about +# is that the symlink resolves to something actually executable. +set +e +help_output=$("$BIN_NAME" --help 2>&1) +help_status=$? +set -e +log "Exit status: $help_status" +log "Output (first 40 lines):" +printf '%s\n' "$help_output" | head -40 | tee -a "$LOG_FILE" +echo "$help_output" >> "$LOG_FILE" + +# 126 = permission denied, 127 = command not found / shim missing. +# Anything else means the bin shim and its dependencies (Node.js, jar, +# etc.) actually loaded and ran. +if [ "$help_status" -eq 126 ] || [ "$help_status" -eq 127 ]; then + log "FAIL: '$BIN_NAME' could not be executed (exit $help_status)" + exit 1 +fi +log "OK: '$BIN_NAME' executed (exit $help_status)" + +log "All checks passed." +exit 0