Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/e2e-npm-link-tests.yml
Original file line number Diff line number Diff line change
@@ -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/
10 changes: 7 additions & 3 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
35 changes: 23 additions & 12 deletions cli/src/main/java/ca/weblite/jdeploy/JDeploy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name> --source=<url> : Uninstall by package name\n"
+ " run : Launch the installed GUI application\n"
Expand Down Expand Up @@ -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<ca.weblite.jdeploy.ai.models.AIToolType> 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);
Expand Down Expand Up @@ -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<ca.weblite.jdeploy.ai.models.AIToolType> 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);
Expand Down Expand Up @@ -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)");
Expand All @@ -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();
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
)
)
);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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")) {
Expand All @@ -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"));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
)
)
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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..."

Expand Down
8 changes: 5 additions & 3 deletions cli/src/main/resources/scripts/linux/install-and-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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/"));
}
}
4 changes: 3 additions & 1 deletion tests/e2e/e2e-local-test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading