diff --git a/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanel.java b/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanel.java
index 8074e7b8..1c22a216 100644
--- a/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanel.java
+++ b/cli/src/main/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanel.java
@@ -30,6 +30,9 @@ public class CliCommandsPanel extends JPanel {
private JButton removeButton;
private JTextField nameField;
private JTextField descriptionField;
+ private JCheckBox updaterCheckbox;
+ private JCheckBox launcherCheckbox;
+ private JCheckBox serviceControllerCheckbox;
private JTextArea argsField;
private JLabel validationLabel;
private ActionListener changeListener;
@@ -179,6 +182,56 @@ private JPanel createRightPanel() {
formPanel.add(Box.createVerticalStrut(10));
+ // Implements field (checkboxes)
+ JPanel implLabelPanel = new JPanel();
+ implLabelPanel.setOpaque(false);
+ implLabelPanel.setLayout(new BoxLayout(implLabelPanel, BoxLayout.X_AXIS));
+ JLabel implLabel = new JLabel("Implements");
+ implLabel.setFont(implLabel.getFont().deriveFont(Font.BOLD));
+ implLabelPanel.add(implLabel);
+ implLabelPanel.add(Box.createHorizontalStrut(5));
+ implLabelPanel.add(createInfoIcon("Special behaviors for this command.
See individual checkbox tooltips for details."));
+ implLabelPanel.add(Box.createHorizontalGlue());
+ implLabelPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
+ implLabelPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, implLabel.getPreferredSize().height));
+ formPanel.add(implLabelPanel);
+
+ // Checkboxes panel
+ JPanel checkboxPanel = new JPanel();
+ checkboxPanel.setOpaque(false);
+ checkboxPanel.setLayout(new FlowLayout(FlowLayout.LEFT, 0, 0));
+ checkboxPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+ updaterCheckbox = new JCheckBox("Updater");
+ updaterCheckbox.setOpaque(false);
+ updaterCheckbox.setToolTipText("Intercepts 'update' argument to trigger app updates.
" +
+ "Example: myapp-cli update → calls launcher with --jdeploy:update");
+ updaterCheckbox.addActionListener(e -> onFieldChanged());
+ checkboxPanel.add(updaterCheckbox);
+ checkboxPanel.add(Box.createHorizontalStrut(15));
+
+ launcherCheckbox = new JCheckBox("Launcher");
+ launcherCheckbox.setOpaque(false);
+ launcherCheckbox.setToolTipText("Launches the desktop GUI application.
" +
+ "Arguments are passed as file paths or URLs to open.
" +
+ "macOS: Uses 'open -a MyApp.app', others call binary directly.");
+ launcherCheckbox.addActionListener(e -> onFieldChanged());
+ checkboxPanel.add(launcherCheckbox);
+ checkboxPanel.add(Box.createHorizontalStrut(15));
+
+ serviceControllerCheckbox = new JCheckBox("Service Controller");
+ serviceControllerCheckbox.setOpaque(false);
+ serviceControllerCheckbox.setToolTipText("Intercepts 'service' as first argument for daemon control.
" +
+ "Example: myappctl service start → calls launcher with --jdeploy:service start");
+ serviceControllerCheckbox.addActionListener(e -> onFieldChanged());
+ checkboxPanel.add(serviceControllerCheckbox);
+
+ // Constrain the height of the checkbox panel to prevent vertical expansion
+ checkboxPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, updaterCheckbox.getPreferredSize().height));
+ formPanel.add(checkboxPanel);
+
+ formPanel.add(Box.createVerticalStrut(10));
+
// Arguments field
JPanel argsLabelPanel = new JPanel();
argsLabelPanel.setOpaque(false);
@@ -312,6 +365,9 @@ public void load(JSONObject jdeploy) {
commandsModel.clear();
nameField.setText("");
descriptionField.setText("");
+ updaterCheckbox.setSelected(false);
+ launcherCheckbox.setSelected(false);
+ serviceControllerCheckbox.setSelected(false);
argsField.setForeground(Color.GRAY);
argsField.setText(ARGS_PLACEHOLDER);
validationLabel.setText(" ");
@@ -434,6 +490,9 @@ private void onCommandSelected(ListSelectionEvent evt) {
if (index < 0) {
nameField.setText("");
descriptionField.setText("");
+ updaterCheckbox.setSelected(false);
+ launcherCheckbox.setSelected(false);
+ serviceControllerCheckbox.setSelected(false);
argsField.setForeground(Color.GRAY);
argsField.setText(ARGS_PLACEHOLDER);
validationLabel.setText(" ");
@@ -453,12 +512,31 @@ private void loadCommandForEditing(String commandName) {
isUpdatingUI = true;
try {
nameField.setText(commandName);
-
+
// Load description and args from the backing data model
JSONObject spec = commandsModel.get(commandName);
if (spec != null) {
descriptionField.setText(spec.optString("description", ""));
-
+
+ // Load implements array
+ updaterCheckbox.setSelected(false);
+ launcherCheckbox.setSelected(false);
+ serviceControllerCheckbox.setSelected(false);
+
+ if (spec.has("implements")) {
+ JSONArray implArray = spec.getJSONArray("implements");
+ for (int i = 0; i < implArray.length(); i++) {
+ String impl = implArray.getString(i);
+ if (CommandSpecParser.IMPL_UPDATER.equals(impl)) {
+ updaterCheckbox.setSelected(true);
+ } else if (CommandSpecParser.IMPL_LAUNCHER.equals(impl)) {
+ launcherCheckbox.setSelected(true);
+ } else if (CommandSpecParser.IMPL_SERVICE_CONTROLLER.equals(impl)) {
+ serviceControllerCheckbox.setSelected(true);
+ }
+ }
+ }
+
// Load args - join array elements with newlines
if (spec.has("args")) {
JSONArray argsArray = spec.getJSONArray("args");
@@ -475,10 +553,13 @@ private void loadCommandForEditing(String commandName) {
}
} else {
descriptionField.setText("");
+ updaterCheckbox.setSelected(false);
+ launcherCheckbox.setSelected(false);
+ serviceControllerCheckbox.setSelected(false);
argsField.setForeground(Color.GRAY);
argsField.setText(ARGS_PLACEHOLDER);
}
-
+
validationLabel.setText(" ");
} finally {
isUpdatingUI = false;
@@ -537,7 +618,7 @@ private void onNameChanged() {
if (spec == null) {
spec = new JSONObject();
}
-
+
// Save current form values into the spec before moving it
String desc = descriptionField.getText().trim();
if (!desc.isEmpty()) {
@@ -545,7 +626,24 @@ private void onNameChanged() {
} else {
spec.remove("description");
}
-
+
+ // Save implements array
+ JSONArray implArray = new JSONArray();
+ if (updaterCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_UPDATER);
+ }
+ if (launcherCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_LAUNCHER);
+ }
+ if (serviceControllerCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_SERVICE_CONTROLLER);
+ }
+ if (implArray.length() > 0) {
+ spec.put("implements", implArray);
+ } else {
+ spec.remove("implements");
+ }
+
String argsText = argsField.getText().trim();
boolean isPlaceholder = argsText.equals(ARGS_PLACEHOLDER.trim()) && argsField.getForeground().equals(Color.GRAY);
@@ -566,7 +664,7 @@ private void onNameChanged() {
} else {
spec.remove("args");
}
-
+
commandsModel.put(name, spec);
// Update the list (this will not trigger selection change)
@@ -605,6 +703,9 @@ private void removeSelectedCommand() {
removeButton.setEnabled(false);
nameField.setText("");
descriptionField.setText("");
+ updaterCheckbox.setSelected(false);
+ launcherCheckbox.setSelected(false);
+ serviceControllerCheckbox.setSelected(false);
argsField.setForeground(Color.GRAY);
argsField.setText(ARGS_PLACEHOLDER);
validationLabel.setText(" ");
@@ -631,6 +732,21 @@ private JSONObject buildCommandSpec(String name) {
spec.put("description", desc);
}
+ // Build implements array
+ JSONArray implArray = new JSONArray();
+ if (updaterCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_UPDATER);
+ }
+ if (launcherCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_LAUNCHER);
+ }
+ if (serviceControllerCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_SERVICE_CONTROLLER);
+ }
+ if (implArray.length() > 0) {
+ spec.put("implements", implArray);
+ }
+
String argsText = argsField.getText().trim();
// Don't save placeholder text as actual args
boolean isPlaceholder = argsText.equals(ARGS_PLACEHOLDER.trim()) && argsField.getForeground().equals(Color.GRAY);
@@ -670,7 +786,7 @@ private void onFieldChanged() {
spec = new JSONObject();
commandsModel.put(currentName, spec);
}
-
+
// Update description
String desc = descriptionField.getText().trim();
if (!desc.isEmpty()) {
@@ -678,7 +794,24 @@ private void onFieldChanged() {
} else {
spec.remove("description");
}
-
+
+ // Update implements array
+ JSONArray implArray = new JSONArray();
+ if (updaterCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_UPDATER);
+ }
+ if (launcherCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_LAUNCHER);
+ }
+ if (serviceControllerCheckbox.isSelected()) {
+ implArray.put(CommandSpecParser.IMPL_SERVICE_CONTROLLER);
+ }
+ if (implArray.length() > 0) {
+ spec.put("implements", implArray);
+ } else {
+ spec.remove("implements");
+ }
+
// Update args
String argsText = argsField.getText().trim();
boolean isPlaceholder = argsText.equals(ARGS_PLACEHOLDER.trim()) && argsField.getForeground().equals(Color.GRAY);
diff --git a/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanelTest.java b/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanelTest.java
index 8b167417..7d656d89 100644
--- a/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanelTest.java
+++ b/cli/src/test/java/ca/weblite/jdeploy/gui/tabs/CliCommandsPanelTest.java
@@ -183,4 +183,78 @@ public void testEditDescriptionAndSwitchCommand() {
assertEquals(1, cmd2Args.length());
assertEquals("arg2", cmd2Args.getString(0));
}
+
+ @Test
+ public void testImplementsPropertySaveAndLoad() {
+ // Create a command with implements array
+ JSONObject jdeploy = new JSONObject();
+ JSONObject commands = new JSONObject();
+
+ JSONObject cmd1 = new JSONObject();
+ cmd1.put("description", "Updater command");
+ JSONArray impl1 = new JSONArray();
+ impl1.put("updater");
+ cmd1.put("implements", impl1);
+
+ JSONObject cmd2 = new JSONObject();
+ cmd2.put("description", "Launcher command");
+ JSONArray impl2 = new JSONArray();
+ impl2.put("launcher");
+ cmd2.put("implements", impl2);
+
+ JSONObject cmd3 = new JSONObject();
+ cmd3.put("description", "Service controller with updater");
+ JSONArray impl3 = new JSONArray();
+ impl3.put("service_controller");
+ impl3.put("updater");
+ cmd3.put("implements", impl3);
+
+ commands.put("updater-cmd", cmd1);
+ commands.put("launcher-cmd", cmd2);
+ commands.put("service-cmd", cmd3);
+ jdeploy.put("commands", commands);
+
+ // Load and verify
+ panel.load(jdeploy);
+
+ // Save and verify implements are preserved
+ JSONObject saved = new JSONObject();
+ panel.save(saved);
+
+ JSONObject savedCommands = saved.getJSONObject("commands");
+ assertEquals(3, savedCommands.length());
+
+ // Verify updater-cmd has updater implementation
+ assertTrue(savedCommands.has("updater-cmd"));
+ JSONObject savedCmd1 = savedCommands.getJSONObject("updater-cmd");
+ assertTrue(savedCmd1.has("implements"));
+ JSONArray savedImpl1 = savedCmd1.getJSONArray("implements");
+ assertEquals(1, savedImpl1.length());
+ assertEquals("updater", savedImpl1.getString(0));
+
+ // Verify launcher-cmd has launcher implementation
+ assertTrue(savedCommands.has("launcher-cmd"));
+ JSONObject savedCmd2 = savedCommands.getJSONObject("launcher-cmd");
+ assertTrue(savedCmd2.has("implements"));
+ JSONArray savedImpl2 = savedCmd2.getJSONArray("implements");
+ assertEquals(1, savedImpl2.length());
+ assertEquals("launcher", savedImpl2.getString(0));
+
+ // Verify service-cmd has both implementations
+ assertTrue(savedCommands.has("service-cmd"));
+ JSONObject savedCmd3 = savedCommands.getJSONObject("service-cmd");
+ assertTrue(savedCmd3.has("implements"));
+ JSONArray savedImpl3 = savedCmd3.getJSONArray("implements");
+ assertEquals(2, savedImpl3.length());
+ // Note: Order might vary, so check both are present
+ boolean hasServiceController = false;
+ boolean hasUpdater = false;
+ for (int i = 0; i < savedImpl3.length(); i++) {
+ String impl = savedImpl3.getString(i);
+ if ("service_controller".equals(impl)) hasServiceController = true;
+ if ("updater".equals(impl)) hasUpdater = true;
+ }
+ assertTrue(hasServiceController, "Should have service_controller implementation");
+ assertTrue(hasUpdater, "Should have updater implementation");
+ }
}
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 f4ba5389..47815357 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/Main.java
@@ -9,6 +9,7 @@
import ca.weblite.jdeploy.installer.events.InstallationFormEventDispatcher;
import ca.weblite.jdeploy.installer.linux.LinuxAdminLauncherGenerator;
import ca.weblite.jdeploy.installer.linux.MimeTypeHelper;
+import ca.weblite.jdeploy.installer.logging.InstallationLogger;
import ca.weblite.jdeploy.installer.mac.MacAdminLauncherGenerator;
import ca.weblite.jdeploy.installer.services.InstallationDetectionService;
import ca.weblite.jdeploy.installer.uninstall.FileUninstallManifestRepository;
@@ -950,206 +951,274 @@ private void install() throws Exception {
new UIAwareCollisionHandler(uiFactory, installationForm)
);
} else if (Platform.getSystemPlatform().isMac()) {
- File jdeployAppsDir = new File(System.getProperty("user.home") + File.separator + "Applications");
- if (!jdeployAppsDir.exists()) {
- jdeployAppsDir.mkdirs();
- }
- String nameSuffix = "";
- if (appInfo().getNpmVersion().startsWith("0.0.0-")) {
- nameSuffix = " " + appInfo().getNpmVersion().substring(appInfo().getNpmVersion().indexOf("-") + 1).trim();
+ // Create installation logger for Mac
+ InstallationLogger macLogger = null;
+ try {
+ macLogger = new InstallationLogger(fullyQualifiedPackageName, InstallationLogger.OperationType.INSTALL);
+ } catch (IOException e) {
+ System.err.println("Warning: Failed to create installation logger: " + e.getMessage());
}
+ final InstallationLogger macInstallLogger = macLogger;
- String appName = appInfo().getTitle() + nameSuffix;
- File installAppPath = new File(jdeployAppsDir, appName+".app");
- if (installAppPath.exists() && installationSettings.isOverwriteApp()) {
- FileUtils.deleteDirectory(installAppPath);
- }
- File tmpAppPath = null;
- for (File candidateApp : new File(tmpBundles, target).listFiles()) {
- if (candidateApp.getName().endsWith(".app")) {
- int result = Runtime.getRuntime().exec(new String[]{"mv", candidateApp.getAbsolutePath(), installAppPath.getAbsolutePath()}).waitFor();
- if (result != 0) {
- String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log";
- String technicalMessage = "Failed to move application bundle to " + installAppPath.getAbsolutePath() + ". mv command returned exit code " + result;
- String userMessage = "
" +
- "Installation Failed
" +
- "Could not install the application to:
" + jdeployAppsDir.getAbsolutePath() + "
" +
- "Possible causes:
" +
- "" +
- "- You don't have write permission to the Applications directory
" +
- "- The application is currently running (please close it and try again)
" +
- "
" +
- "For technical details, check the log file:
" +
- logPath + "
" +
- "";
- throw new UserLangRuntimeException(technicalMessage, userMessage);
+ try {
+ File jdeployAppsDir = new File(System.getProperty("user.home") + File.separator + "Applications");
+ if (!jdeployAppsDir.exists()) {
+ jdeployAppsDir.mkdirs();
+ if (macInstallLogger != null) {
+ macInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED,
+ jdeployAppsDir.getAbsolutePath(), "User Applications directory");
}
- break;
}
- }
- if (!installAppPath.exists()) {
- String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log";
- String technicalMessage = "Application bundle does not exist at " + installAppPath.getAbsolutePath() + " after installation attempt";
- String userMessage = "" +
- "Installation Failed
" +
- "Could not install the application to:
" + jdeployAppsDir.getAbsolutePath() + "
" +
- "Possible causes:
" +
- "" +
- "- You don't have write permission to the Applications directory
" +
- "- The application is currently running (please close it and try again)
" +
- "
" +
- "For technical details, check the log file:
" +
- logPath + "
" +
- "";
- throw new UserLangRuntimeException(technicalMessage, userMessage);
- }
- installedApp = installAppPath;
- File adminWrapper = null;
- File desktopAlias = null;
-
- if (appInfo().isRequireRunAsAdmin() || appInfo().isAllowRunAsAdmin()) {
- MacAdminLauncherGenerator macAdminLauncherGenerator = new MacAdminLauncherGenerator();
- adminWrapper = macAdminLauncherGenerator.getAdminLauncherFile(installedApp);
- if (adminWrapper.exists()) {
- // delete the old recursively
- FileUtils.deleteDirectory(adminWrapper);
+ String nameSuffix = "";
+ if (appInfo().getNpmVersion().startsWith("0.0.0-")) {
+ nameSuffix = " " + appInfo().getNpmVersion().substring(appInfo().getNpmVersion().indexOf("-") + 1).trim();
}
- adminWrapper = new MacAdminLauncherGenerator().generateAdminLauncher(installedApp);
- }
- if (installationSettings.isAddToDesktop()) {
- desktopAlias = new File(System.getProperty("user.home") + File.separator + "Desktop" + File.separator + appName + ".app");
- if (desktopAlias.exists()) {
- desktopAlias.delete();
+ String appName = appInfo().getTitle() + nameSuffix;
+ File installAppPath = new File(jdeployAppsDir, appName+".app");
+ if (installAppPath.exists() && installationSettings.isOverwriteApp()) {
+ FileUtils.deleteDirectory(installAppPath);
+ if (macInstallLogger != null) {
+ macInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED,
+ installAppPath.getAbsolutePath(), "Existing app bundle (overwrite)");
+ }
}
- String targetPath = installAppPath.getAbsolutePath();
- if (adminWrapper != null && appInfo().isRequireRunAsAdmin()) {
- targetPath = adminWrapper.getAbsolutePath();
+ File tmpAppPath = null;
+ for (File candidateApp : new File(tmpBundles, target).listFiles()) {
+ if (candidateApp.getName().endsWith(".app")) {
+ int result = Runtime.getRuntime().exec(new String[]{"mv", candidateApp.getAbsolutePath(), installAppPath.getAbsolutePath()}).waitFor();
+ if (result != 0) {
+ String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log";
+ String technicalMessage = "Failed to move application bundle to " + installAppPath.getAbsolutePath() + ". mv command returned exit code " + result;
+ String userMessage = "" +
+ "Installation Failed
" +
+ "Could not install the application to:
" + jdeployAppsDir.getAbsolutePath() + "
" +
+ "Possible causes:
" +
+ "" +
+ "- You don't have write permission to the Applications directory
" +
+ "- The application is currently running (please close it and try again)
" +
+ "
" +
+ "For technical details, check the log file:
" +
+ logPath + "
" +
+ "";
+ throw new UserLangRuntimeException(technicalMessage, userMessage);
+ }
+ if (macInstallLogger != null) {
+ macInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED,
+ installAppPath.getAbsolutePath(), "Installed application bundle");
+ }
+ break;
+ }
}
- int result = Runtime.getRuntime().exec(new String[]{"ln", "-s", targetPath, desktopAlias.getAbsolutePath()}).waitFor();
- if (result != 0) {
- throw new RuntimeException("Failed to make desktop alias.");
+ if (!installAppPath.exists()) {
+ String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log";
+ String technicalMessage = "Application bundle does not exist at " + installAppPath.getAbsolutePath() + " after installation attempt";
+ String userMessage = "" +
+ "Installation Failed
" +
+ "Could not install the application to:
" + jdeployAppsDir.getAbsolutePath() + "
" +
+ "Possible causes:
" +
+ "" +
+ "- You don't have write permission to the Applications directory
" +
+ "- The application is currently running (please close it and try again)
" +
+ "
" +
+ "For technical details, check the log file:
" +
+ logPath + "
" +
+ "";
+ throw new UserLangRuntimeException(technicalMessage, userMessage);
}
- }
-
- if (installationSettings.isAddToDock()) {
- /*
- #!/bin/bash
- myapp="//Applications//System Preferences.app"
- defaults write com.apple.dock persistent-apps -array-add "tile-datafile-data_CFURLString$myapp_CFURLStringType0"
- osascript -e 'tell application "Dock" to quit'
- osascript -e 'tell application "Dock" to activate'
- */
- String targetPath = installAppPath.getAbsolutePath();
- if (adminWrapper != null && appInfo().isRequireRunAsAdmin()) {
- targetPath = adminWrapper.getAbsolutePath();
+ installedApp = installAppPath;
+ File adminWrapper = null;
+ File desktopAlias = null;
+
+ if (appInfo().isRequireRunAsAdmin() || appInfo().isAllowRunAsAdmin()) {
+ MacAdminLauncherGenerator macAdminLauncherGenerator = new MacAdminLauncherGenerator();
+ adminWrapper = macAdminLauncherGenerator.getAdminLauncherFile(installedApp);
+ if (adminWrapper.exists()) {
+ // delete the old recursively
+ FileUtils.deleteDirectory(adminWrapper);
+ }
+ adminWrapper = new MacAdminLauncherGenerator().generateAdminLauncher(installedApp);
+ if (macInstallLogger != null) {
+ macInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED,
+ adminWrapper.getAbsolutePath(), "Admin launcher wrapper");
+ }
}
- String myapp = targetPath.replace('/', '#').replace("#", "//");
- File shellScript = File.createTempFile("installondock", ".sh");
- shellScript.deleteOnExit();
- System.out.println("Adding to dock: "+myapp);
- String[] commands = new String[]{
- "/usr/bin/defaults write com.apple.dock persistent-apps -array-add \"tile-datafile-data_CFURLString"+myapp+"_CFURLStringType0\"",
- "/usr/bin/osascript -e 'tell application \"Dock\" to quit'",
- "/usr/bin/osascript -e 'tell application \"Dock\" to activate'"
+ if (installationSettings.isAddToDesktop()) {
+ desktopAlias = new File(System.getProperty("user.home") + File.separator + "Desktop" + File.separator + appName + ".app");
+ if (desktopAlias.exists()) {
+ desktopAlias.delete();
+ }
+ String targetPath = installAppPath.getAbsolutePath();
+ if (adminWrapper != null && appInfo().isRequireRunAsAdmin()) {
+ targetPath = adminWrapper.getAbsolutePath();
+ }
+ int result = Runtime.getRuntime().exec(new String[]{"ln", "-s", targetPath, desktopAlias.getAbsolutePath()}).waitFor();
+ if (result != 0) {
+ throw new RuntimeException("Failed to make desktop alias.");
+ }
+ if (macInstallLogger != null) {
+ macInstallLogger.logShortcut(InstallationLogger.FileOperation.CREATED,
+ desktopAlias.getAbsolutePath(), targetPath);
+ }
+ }
- };
- try (PrintWriter printWriter = new PrintWriter(new FileOutputStream(shellScript))) {
- printWriter.println("#!/bin/bash");
- for (String cmd : commands) {
- printWriter.println(cmd);
+ if (installationSettings.isAddToDock()) {
+ /*
+ #!/bin/bash
+ myapp="//Applications//System Preferences.app"
+ defaults write com.apple.dock persistent-apps -array-add "tile-datafile-data_CFURLString$myapp_CFURLStringType0"
+ osascript -e 'tell application "Dock" to quit'
+ osascript -e 'tell application "Dock" to activate'
+ */
+ String targetPath = installAppPath.getAbsolutePath();
+ if (adminWrapper != null && appInfo().isRequireRunAsAdmin()) {
+ targetPath = adminWrapper.getAbsolutePath();
+ }
+ String myapp = targetPath.replace('/', '#').replace("#", "//");
+ File shellScript = File.createTempFile("installondock", ".sh");
+ shellScript.deleteOnExit();
+
+ System.out.println("Adding to dock: "+myapp);
+ String[] commands = new String[]{
+ "/usr/bin/defaults write com.apple.dock persistent-apps -array-add \"tile-datafile-data_CFURLString"+myapp+"_CFURLStringType0\"",
+ "/usr/bin/osascript -e 'tell application \"Dock\" to quit'",
+ "/usr/bin/osascript -e 'tell application \"Dock\" to activate'"
+
+ };
+ try (PrintWriter printWriter = new PrintWriter(new FileOutputStream(shellScript))) {
+ printWriter.println("#!/bin/bash");
+ for (String cmd : commands) {
+ printWriter.println(cmd);
+ }
+ }
+ shellScript.setExecutable(true, false);
+ Runtime.getRuntime().exec(shellScript.getAbsolutePath());
+ if (macInstallLogger != null) {
+ macInstallLogger.logInfo("Added application to Dock: " + targetPath);
}
}
- shellScript.setExecutable(true, false);
- Runtime.getRuntime().exec(shellScript.getAbsolutePath());
- }
- // Collect installation artifacts for manifest
- List cliScriptFiles = new ArrayList<>();
- File cliLauncherSymlink = null;
-
- // Install CLI scripts/launchers on macOS (in ~/.local/bin) if the user requested either feature.
- if (installationSettings.isInstallCliCommands() || installationSettings.isInstallCliLauncher()) {
- File cliLauncher = new File(installAppPath, "Contents" + File.separator + "MacOS" + File.separator + CliInstallerConstants.CLI_LAUNCHER_NAME);
- if (!cliLauncher.exists()) {
- File fallback = new File(installAppPath, "Contents" + File.separator + "MacOS" + File.separator + "Client4JLauncher");
- if (fallback.exists()) {
- cliLauncher = fallback;
+ // Collect installation artifacts for manifest
+ List cliScriptFiles = new ArrayList<>();
+ File cliLauncherSymlink = null;
+
+ // Install CLI scripts/launchers on macOS (in ~/.local/bin) if the user requested either feature.
+ if (installationSettings.isInstallCliCommands() || installationSettings.isInstallCliLauncher()) {
+ File cliLauncher = new File(installAppPath, "Contents" + File.separator + "MacOS" + File.separator + CliInstallerConstants.CLI_LAUNCHER_NAME);
+ if (!cliLauncher.exists()) {
+ File fallback = new File(installAppPath, "Contents" + File.separator + "MacOS" + File.separator + "Client4JLauncher");
+ if (fallback.exists()) {
+ cliLauncher = fallback;
+ }
+ }
+
+ List cliCommands = npmPackageVersion() != null ? npmPackageVersion().getCommands() : Collections.emptyList();
+ MacCliCommandInstaller macCliInstaller = new MacCliCommandInstaller();
+ // Wire collision handler for GUI-aware prompting
+ macCliInstaller.setCollisionHandler(new UIAwareCollisionHandler(uiFactory, installationForm));
+ macCliInstaller.setInstallationLogger(macInstallLogger);
+ cliScriptFiles.addAll(macCliInstaller.installCommands(cliLauncher, cliCommands, installationSettings));
+
+ // Track the CLI launcher symlink if it was created
+ if (installationSettings.isInstallCliLauncher() && installationSettings.isCommandLineSymlinkCreated()) {
+ String commandName = deriveCommandName();
+ File binDir = new File(System.getProperty("user.home"), ".jdeploy" + File.separator + "bin-" + ArchitectureUtil.getArchitectureSuffix());
+ cliLauncherSymlink = new File(binDir, commandName);
}
}
- List commands = npmPackageVersion() != null ? npmPackageVersion().getCommands() : Collections.emptyList();
- MacCliCommandInstaller macCliInstaller = new MacCliCommandInstaller();
- // Wire collision handler for GUI-aware prompting
- macCliInstaller.setCollisionHandler(new UIAwareCollisionHandler(uiFactory, installationForm));
- cliScriptFiles.addAll(macCliInstaller.installCommands(cliLauncher, commands, installationSettings));
-
- // Track the CLI launcher symlink if it was created
- if (installationSettings.isInstallCliLauncher() && installationSettings.isCommandLineSymlinkCreated()) {
- String commandName = deriveCommandName();
- File binDir = new File(System.getProperty("user.home"), ".jdeploy" + File.separator + "bin-" + ArchitectureUtil.getArchitectureSuffix());
- cliLauncherSymlink = new File(binDir, commandName);
+ // Build and persist uninstall manifest
+ persistMacInstallationManifest(installAppPath, adminWrapper, desktopAlias,
+ cliScriptFiles, cliLauncherSymlink, appName);
+ } finally {
+ // Close the installation logger
+ if (macInstallLogger != null) {
+ macInstallLogger.close();
}
}
-
- // Build and persist uninstall manifest
- persistMacInstallationManifest(installAppPath, adminWrapper, desktopAlias,
- cliScriptFiles, cliLauncherSymlink, appName);
} else if (Platform.getSystemPlatform().isLinux()) {
- File tmpExePath = null;
- for (File exeCandidate : Objects.requireNonNull(new File(tmpBundles, target).listFiles())) {
- tmpExePath = exeCandidate;
- }
- if (tmpExePath == null) {
- throw new RuntimeException("Failed to find launcher file after creation. Something must have gone wrong in generation process");
- }
- File userHome = new File(System.getProperty("user.home"));
- File jdeployHome = new File(userHome, ".jdeploy");
- File appsDir = new File(jdeployHome, "apps");
- File appDir = new File(appsDir, fullyQualifiedPackageName);
- appDir.mkdirs();
-
- String nameSuffix = "";
- String titleSuffix = "";
- if (appInfo().getNpmVersion().startsWith("0.0.0-")) {
- String v = appInfo().getNpmVersion();
- nameSuffix = "-" +v.substring(v.indexOf("-") + 1).trim();
- titleSuffix = "-" + v.substring(v.indexOf("-") + 1).trim();
+ // Create installation logger for Linux
+ InstallationLogger linuxLogger = null;
+ try {
+ linuxLogger = new InstallationLogger(fullyQualifiedPackageName, InstallationLogger.OperationType.INSTALL);
+ } catch (IOException e) {
+ System.err.println("Warning: Failed to create installation logger: " + e.getMessage());
}
+ final InstallationLogger linuxInstallLogger = linuxLogger;
- String exeName = deriveLinuxBinaryNameFromTitle(appInfo().getTitle()) + nameSuffix;
- File exePath = new File(appDir, exeName);
try {
- FileUtil.copy(tmpExePath, exePath);
- } catch (IOException e) {
- String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log";
- String technicalMessage = "Failed to copy application launcher to " + exePath.getAbsolutePath() + ": " + e.getMessage();
- String userMessage = "" +
- "Installation Failed
" +
- "Could not install the application to:
" + appDir.getAbsolutePath() + "
" +
- "Possible causes:
" +
- "" +
- "- You don't have write permission to the directory
" +
- "- The application is currently running (please close it and try again)
" +
- "
" +
- "For technical details, check the log file:
" +
- logPath + "
" +
- "";
- throw new UserLangRuntimeException(technicalMessage, userMessage, e);
- }
-
- // Copy the icon.png if it is present
- File bundleIcon = new File(findAppXmlFile().getParentFile(), "icon.png");
- File iconPath = new File(exePath.getParentFile(), "icon.png");
- if (bundleIcon.exists()) {
- FileUtil.copy(bundleIcon, iconPath);
- }
- installLinuxMimetypes();
-
- installLinuxLinks(exePath, appInfo().getTitle() + titleSuffix);
- installedApp = exePath;
+ File tmpExePath = null;
+ for (File exeCandidate : Objects.requireNonNull(new File(tmpBundles, target).listFiles())) {
+ tmpExePath = exeCandidate;
+ }
+ if (tmpExePath == null) {
+ throw new RuntimeException("Failed to find launcher file after creation. Something must have gone wrong in generation process");
+ }
+ File userHome = new File(System.getProperty("user.home"));
+ File jdeployHome = new File(userHome, ".jdeploy");
+ File appsDir = new File(jdeployHome, "apps");
+ File appDir = new File(appsDir, fullyQualifiedPackageName);
+ boolean appDirCreated = !appDir.exists();
+ appDir.mkdirs();
+ if (appDirCreated && linuxInstallLogger != null) {
+ linuxInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED,
+ appDir.getAbsolutePath(), "Application directory");
+ }
+
+ String nameSuffix = "";
+ String titleSuffix = "";
+ if (appInfo().getNpmVersion().startsWith("0.0.0-")) {
+ String v = appInfo().getNpmVersion();
+ nameSuffix = "-" +v.substring(v.indexOf("-") + 1).trim();
+ titleSuffix = "-" + v.substring(v.indexOf("-") + 1).trim();
+ }
+ String exeName = deriveLinuxBinaryNameFromTitle(appInfo().getTitle()) + nameSuffix;
+ File exePath = new File(appDir, exeName);
+ try {
+ FileUtil.copy(tmpExePath, exePath);
+ if (linuxInstallLogger != null) {
+ linuxInstallLogger.logFileOperation(InstallationLogger.FileOperation.CREATED,
+ exePath.getAbsolutePath(), "Application launcher");
+ }
+ } catch (IOException e) {
+ String logPath = System.getProperty("user.home") + "/.jdeploy/log/jdeploy-installer.log";
+ String technicalMessage = "Failed to copy application launcher to " + exePath.getAbsolutePath() + ": " + e.getMessage();
+ String userMessage = "" +
+ "Installation Failed
" +
+ "Could not install the application to:
" + appDir.getAbsolutePath() + "
" +
+ "Possible causes:
" +
+ "" +
+ "- You don't have write permission to the directory
" +
+ "- The application is currently running (please close it and try again)
" +
+ "
" +
+ "For technical details, check the log file:
" +
+ logPath + "
" +
+ "";
+ throw new UserLangRuntimeException(technicalMessage, userMessage, e);
+ }
+
+ // Copy the icon.png if it is present
+ File bundleIcon = new File(findAppXmlFile().getParentFile(), "icon.png");
+ File iconPath = new File(exePath.getParentFile(), "icon.png");
+ if (bundleIcon.exists()) {
+ FileUtil.copy(bundleIcon, iconPath);
+ if (linuxInstallLogger != null) {
+ linuxInstallLogger.logFileOperation(InstallationLogger.FileOperation.CREATED,
+ iconPath.getAbsolutePath(), "Application icon");
+ }
+ }
+ installLinuxMimetypes();
+
+ installLinuxLinks(exePath, appInfo().getTitle() + titleSuffix, linuxInstallLogger);
+ installedApp = exePath;
+ } finally {
+ // Close the installation logger
+ if (linuxInstallLogger != null) {
+ linuxInstallLogger.close();
+ }
+ }
}
File tmpPlatformBundles = new File(tmpBundles, target);
@@ -1671,6 +1740,10 @@ private File addLinuxDesktopFile(File desktopDir, String filePrefix, String titl
}
public void installLinuxLinks(File launcherFile, String title) throws Exception {
+ installLinuxLinks(launcherFile, title, null);
+ }
+
+ public void installLinuxLinks(File launcherFile, String title, InstallationLogger linuxInstallLogger) throws Exception {
if (!launcherFile.exists()) {
throw new IllegalStateException("Launcher "+launcherFile+" does not exist so we cannot install a shortcut to it.");
}
@@ -1680,6 +1753,10 @@ public void installLinuxLinks(File launcherFile, String title) throws Exception
File pngIcon = new File(launcherFile.getParentFile(), "icon.png");
if (!pngIcon.exists()) {
IOUtil.copyResourceToFile(Main.class, "icon.png", pngIcon);
+ if (linuxInstallLogger != null) {
+ linuxInstallLogger.logFileOperation(InstallationLogger.FileOperation.CREATED,
+ pngIcon.getAbsolutePath(), "Default application icon");
+ }
}
boolean hasDesktop = isDesktopEnvironmentAvailable();
@@ -1697,15 +1774,28 @@ public void installLinuxLinks(File launcherFile, String title) throws Exception
File desktopFile = addLinuxDesktopFile(desktopDir, title, title, pngIcon, launcherFile);
if (desktopFile != null) {
desktopFiles.add(desktopFile);
+ if (linuxInstallLogger != null) {
+ linuxInstallLogger.logShortcut(InstallationLogger.FileOperation.CREATED,
+ desktopFile.getAbsolutePath(), launcherFile.getAbsolutePath());
+ }
}
}
if (installationSettings.isAddToPrograms()) {
File homeDir = new File(System.getProperty("user.home"));
File applicationsDir = new File(homeDir, ".local"+File.separator+"share"+File.separator+"applications");
+ boolean appDirCreated = !applicationsDir.exists();
applicationsDir.mkdirs();
+ if (appDirCreated && linuxInstallLogger != null) {
+ linuxInstallLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED,
+ applicationsDir.getAbsolutePath(), "Applications directory");
+ }
File desktopFile = addLinuxDesktopFile(applicationsDir, title, title, pngIcon, launcherFile);
if (desktopFile != null) {
applicationsDesktopFiles.add(desktopFile);
+ if (linuxInstallLogger != null) {
+ linuxInstallLogger.logShortcut(InstallationLogger.FileOperation.CREATED,
+ desktopFile.getAbsolutePath(), launcherFile.getAbsolutePath());
+ }
}
// We need to run update desktop database before file type associations and url schemes will be
@@ -1722,6 +1812,9 @@ public void installLinuxLinks(File launcherFile, String title) throws Exception
}
} else {
System.out.println("No desktop environment detected. Skipping desktop shortcuts and mimetype registration.");
+ if (linuxInstallLogger != null) {
+ linuxInstallLogger.logInfo("No desktop environment detected - skipping desktop shortcuts");
+ }
}
// Install command-line scripts and/or symlink in ~/.local/bin if user requested either feature
@@ -1730,9 +1823,13 @@ public void installLinuxLinks(File launcherFile, String title) throws Exception
LinuxCliCommandInstaller linuxCliInstaller = new LinuxCliCommandInstaller();
// Wire collision handler for GUI-aware prompting
linuxCliInstaller.setCollisionHandler(new UIAwareCollisionHandler(uiFactory, installationForm));
+ linuxCliInstaller.setInstallationLogger(linuxInstallLogger);
cliScriptFiles.addAll(linuxCliInstaller.installCommands(launcherFile, commands, installationSettings));
} else {
System.out.println("Skipping CLI command and launcher installation (user opted out)");
+ if (linuxInstallLogger != null) {
+ linuxInstallLogger.logInfo("CLI command and launcher installation skipped (user opted out)");
+ }
}
// Persist uninstall manifest with all collected artifacts
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/MainDebug.java b/installer/src/main/java/ca/weblite/jdeploy/installer/MainDebug.java
index 684efe8b..6ebbe1c4 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/MainDebug.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/MainDebug.java
@@ -16,13 +16,27 @@ public static void main(String[] args) throws Exception {
String code = args[0];
String version = args.length > 1 ? args[1] : "latest";
- File tempDir = File.createTempFile("jdeploy-debug-", "");
+ File tempDir = File.createTempFile("jdeploy-debug2-", "");
tempDir.delete();
tempDir.mkdirs();
-
+
+ // If client4j.launcher.path is set and exists, copy it to temp directory
+ // so that findInstallFilesDir() will find the downloaded .jdeploy-files
+ // instead of any stale test fixtures near the original launcher path
+ String originalLauncherPath = System.getProperty("client4j.launcher.path");
+ if (originalLauncherPath != null) {
+ File originalLauncher = new File(originalLauncherPath);
+ if (originalLauncher.exists()) {
+ File newLauncher = new File(tempDir, originalLauncher.getName());
+ FileUtils.copyFile(originalLauncher, newLauncher);
+ System.setProperty("client4j.launcher.path", newLauncher.getAbsolutePath());
+ System.out.println("Copied launcher to: " + newLauncher.getAbsolutePath());
+ }
+ }
+
File originalDir = new File(System.getProperty("user.dir"));
System.setProperty("user.dir", tempDir.getAbsolutePath());
-
+
try {
File jdeployFilesDir = DefaultInstallationContext.downloadJDeployBundleForCode(code, version, null);
File targetJDeployFilesDir = new File(tempDir, ".jdeploy-files");
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java
index e937bf56..4c85cd1c 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstaller.java
@@ -1,6 +1,7 @@
package ca.weblite.jdeploy.installer.cli;
import ca.weblite.jdeploy.installer.CliInstallerConstants;
+import ca.weblite.jdeploy.installer.logging.InstallationLogger;
import ca.weblite.jdeploy.installer.models.InstallationSettings;
import ca.weblite.jdeploy.installer.util.CliCommandBinDirResolver;
import ca.weblite.jdeploy.installer.util.DebugLogger;
@@ -34,16 +35,26 @@
public abstract class AbstractUnixCliCommandInstaller implements CliCommandInstaller {
private CollisionHandler collisionHandler = new DefaultCollisionHandler();
+ protected InstallationLogger installationLogger;
/**
* Sets the collision handler for detecting and resolving command name conflicts.
- *
+ *
* @param collisionHandler the handler to use for collision resolution
*/
public void setCollisionHandler(CollisionHandler collisionHandler) {
this.collisionHandler = collisionHandler != null ? collisionHandler : new DefaultCollisionHandler();
}
+ /**
+ * Sets the installation logger for recording detailed operation logs.
+ *
+ * @param logger the installation logger to use
+ */
+ public void setInstallationLogger(InstallationLogger logger) {
+ this.installationLogger = logger;
+ }
+
/**
* Determines the binary directory where CLI commands will be installed.
* Uses CliCommandBinDirResolver to compute the per-app bin directory (~/.jdeploy/bin-{arch}/{fqpn}/).
@@ -215,8 +226,16 @@ public void uninstallCommands(File appDir) {
try {
scriptPath.delete();
System.out.println("Removed command-line script: " + scriptPath.getAbsolutePath());
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.DELETED,
+ scriptPath.getAbsolutePath(), null);
+ }
} catch (Exception e) {
System.err.println("Warning: Failed to remove command script for " + cmdName + ": " + e.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.FAILED,
+ scriptPath.getAbsolutePath(), e.getMessage());
+ }
}
}
}
@@ -234,12 +253,24 @@ public void uninstallCommands(File appDir) {
if (remainingFiles != null) {
for (File f : remainingFiles) {
f.delete();
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED,
+ f.getAbsolutePath(), "Remaining file in bin directory");
+ }
}
}
binDir.delete();
System.out.println("Removed per-app bin directory: " + binDir.getAbsolutePath());
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED,
+ binDir.getAbsolutePath(), "Per-app CLI bin directory");
+ }
} catch (Exception e) {
System.err.println("Warning: Failed to remove per-app bin directory: " + e.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.FAILED,
+ binDir.getAbsolutePath(), e.getMessage());
+ }
}
}
}
@@ -253,8 +284,14 @@ public void uninstallCommands(File appDir) {
try {
manifestRepository.delete(packageName, source);
System.out.println("Deleted manifest for package: " + packageName);
+ if (installationLogger != null) {
+ installationLogger.logInfo("Deleted CLI command manifest for package: " + packageName);
+ }
} catch (Exception e) {
System.err.println("Warning: Failed to delete manifest: " + e.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logError("Failed to delete manifest: " + e.getMessage());
+ }
}
}
@@ -263,8 +300,16 @@ public void uninstallCommands(File appDir) {
if (metadataFile.exists()) {
try {
metadataFile.delete();
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED,
+ metadataFile.getAbsolutePath(), "CLI metadata file");
+ }
} catch (Exception e) {
System.err.println("Warning: Failed to remove metadata file: " + e.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.FAILED,
+ metadataFile.getAbsolutePath(), e.getMessage());
+ }
}
}
}
@@ -344,10 +389,18 @@ protected List installCommandScripts(File launcherPath, List
if (action == CollisionAction.SKIP) {
System.out.println("Skipping command '" + cmdName + "' - already owned by another app");
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.SKIPPED_COLLISION,
+ scriptPath.getAbsolutePath(), "Owned by another app: " + existingLauncherPath);
+ }
continue;
}
// OVERWRITE - fall through to delete and recreate
System.out.println("Overwriting command '" + cmdName + "' from another app");
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.OVERWRITTEN,
+ scriptPath.getAbsolutePath(), "Overwriting from another app: " + existingLauncherPath);
+ }
}
// Same app or couldn't parse - silently overwrite
try {
@@ -358,11 +411,19 @@ protected List installCommandScripts(File launcherPath, List
}
try {
- writeCommandScript(scriptPath, launcherPath.getAbsolutePath(), cmdName, command.getArgs());
+ writeCommandScript(scriptPath, launcherPath.getAbsolutePath(), command);
System.out.println("Created command-line script: " + scriptPath.getAbsolutePath());
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.CREATED,
+ scriptPath.getAbsolutePath(), null);
+ }
createdFiles.add(scriptPath);
} catch (IOException ioe) {
System.err.println("Warning: Failed to create command script for " + cmdName + ": " + ioe.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(cmdName, InstallationLogger.FileOperation.FAILED,
+ scriptPath.getAbsolutePath(), ioe.getMessage());
+ }
}
}
@@ -375,11 +436,10 @@ protected List installCommandScripts(File launcherPath, List
*
* @param scriptPath the path where the script should be created
* @param launcherPath the path to the launcher executable
- * @param commandName the command name to invoke
- * @param args additional command-line arguments (if any)
+ * @param command the command specification including name, args, and implementations
* @throws IOException if the script cannot be created
*/
- protected abstract void writeCommandScript(File scriptPath, String launcherPath, String commandName, List args) throws IOException;
+ protected abstract void writeCommandScript(File scriptPath, String launcherPath, CommandSpec command) throws IOException;
/**
* Computes a user-friendly display path (using ~ for home directory).
@@ -436,12 +496,16 @@ protected boolean ensureBinDirExists(File binDir) {
boolean created = binDir.mkdirs();
DebugLogger.log("mkdirs() returned: " + created);
-
+
if (!created) {
// Check if it was created by another process (race condition)
if (binDir.exists() && binDir.isDirectory()) {
DebugLogger.log("Directory now exists (created by another process)");
System.out.println("Created " + displayPath + " directory");
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED,
+ binDir.getAbsolutePath(), "CLI bin directory (race condition)");
+ }
return true;
}
@@ -453,22 +517,38 @@ protected boolean ensureBinDirExists(File binDir) {
DebugLogger.log(" Parent now exists: " + parent.exists());
DebugLogger.log(" Parent now canWrite: " + parent.canWrite());
}
-
+
System.err.println("Warning: Failed to create " + displayPath + " directory");
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.FAILED,
+ binDir.getAbsolutePath(), "Failed to create CLI bin directory");
+ }
return false;
}
DebugLogger.log("Successfully created directory: " + binDir.getAbsolutePath());
System.out.println("Created " + displayPath + " directory");
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED,
+ binDir.getAbsolutePath(), "CLI bin directory");
+ }
} else {
DebugLogger.log("binDir already exists: " + binDir.getAbsolutePath());
DebugLogger.log(" isDirectory: " + binDir.isDirectory());
DebugLogger.log(" canWrite: " + binDir.canWrite());
-
+
if (!binDir.isDirectory()) {
DebugLogger.log("ensureBinDirExists() failed: path exists but is not a directory");
System.err.println("Warning: " + displayPath + " exists but is not a directory");
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.FAILED,
+ binDir.getAbsolutePath(), "Path exists but is not a directory");
+ }
return false;
}
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.SKIPPED_EXISTS,
+ binDir.getAbsolutePath(), "CLI bin directory already exists");
+ }
}
return true;
}
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java
index 93b954a2..5e0935fd 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/LinuxCliCommandInstaller.java
@@ -1,6 +1,7 @@
package ca.weblite.jdeploy.installer.cli;
import ca.weblite.jdeploy.installer.CliInstallerConstants;
+import ca.weblite.jdeploy.installer.logging.InstallationLogger;
import ca.weblite.jdeploy.installer.models.InstallationSettings;
import ca.weblite.jdeploy.models.CommandSpec;
@@ -62,6 +63,9 @@ public List installCommands(File launcherPath, List commands,
if (anyCreated) {
// Add per-app commands directory to PATH
addToPath(commandsBinDir);
+ if (installationLogger != null) {
+ installationLogger.logPathChange(true, commandsBinDir.getAbsolutePath(), "Linux CLI commands");
+ }
}
}
}
@@ -83,14 +87,25 @@ public List installCommands(File launcherPath, List commands,
try {
Files.createSymbolicLink(symlinkPath.toPath(), launcherPath.toPath());
System.out.println("Created command-line symlink: " + symlinkPath.getAbsolutePath());
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.CREATED,
+ symlinkPath.getAbsolutePath(), launcherPath.getAbsolutePath());
+ }
settings.setCommandLineSymlinkCreated(true);
createdFiles.add(symlinkPath);
anyCreated = true;
// Add ~/.local/bin to PATH
addToPath(launcherBinDir);
+ if (installationLogger != null) {
+ installationLogger.logPathChange(true, launcherBinDir.getAbsolutePath(), "Linux CLI launcher");
+ }
} catch (Exception e) {
System.err.println("Warning: Failed to create command-line symlink: " + e.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.FAILED,
+ symlinkPath.getAbsolutePath(), e.getMessage());
+ }
}
}
}
@@ -146,10 +161,17 @@ public File installLauncher(File launcherPath, String commandName, InstallationS
// Create symlink to the launcher
Files.createSymbolicLink(symlinkPath.toPath(), launcherPath.toPath());
System.out.println("Created launcher symlink: " + symlinkPath.getAbsolutePath());
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.CREATED,
+ symlinkPath.getAbsolutePath(), launcherPath.getAbsolutePath());
+ }
settings.setCommandLineSymlinkCreated(true);
// Update PATH and save metadata
boolean pathUpdated = addToPath(localBinDir);
+ if (pathUpdated && installationLogger != null) {
+ installationLogger.logPathChange(true, localBinDir.getAbsolutePath(), "Linux CLI launcher (installLauncher)");
+ }
File appDir = launcherPath.getParentFile();
// Save metadata to launcher's parent directory if it differs from bin, otherwise use bin
File metadataDir = (appDir != null && !appDir.equals(localBinDir)) ? appDir : localBinDir;
@@ -158,13 +180,17 @@ public File installLauncher(File launcherPath, String commandName, InstallationS
return symlinkPath;
} catch (IOException ioe) {
System.err.println("Warning: Failed to create launcher symlink: " + ioe.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.FAILED,
+ symlinkPath.getAbsolutePath(), ioe.getMessage());
+ }
return null;
}
}
@Override
- protected void writeCommandScript(File scriptPath, String launcherPath, String commandName, List args) throws IOException {
- String content = generateContent(launcherPath, commandName);
+ protected void writeCommandScript(File scriptPath, String launcherPath, CommandSpec command) throws IOException {
+ String content = generateContent(launcherPath, command);
try (FileOutputStream fos = new FileOutputStream(scriptPath)) {
fos.write(content.getBytes(StandardCharsets.UTF_8));
}
@@ -175,15 +201,64 @@ protected void writeCommandScript(File scriptPath, String launcherPath, String c
/**
* Generate the content of a POSIX shell script that exec's the given launcher with
* the configured command name and forwards user-supplied args.
+ * Handles special implementations: updater, launcher, service_controller.
*
* @param launcherPath Absolute path to the CLI-capable launcher binary.
- * @param commandName The command name to pass as --jdeploy:command=.
+ * @param command The command specification including implementations.
* @return Script content (including shebang and trailing newline).
*/
- private static String generateContent(String launcherPath, String commandName) {
+ private static String generateContent(String launcherPath, CommandSpec command) {
StringBuilder sb = new StringBuilder();
sb.append("#!/bin/sh\n");
- sb.append("exec \"").append(escapeDoubleQuotes(launcherPath)).append("\" ").append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName).append(" -- \"$@\"\n");
+
+ String escapedLauncher = escapeDoubleQuotes(launcherPath);
+ String commandName = command.getName();
+ List implementations = command.getImplementations();
+
+ // Check for launcher implementation (highest priority, mutually exclusive)
+ if (implementations.contains("launcher")) {
+ // For launcher: just execute the binary directly, passing all args
+ sb.append("exec \"").append(escapedLauncher).append("\" \"$@\"\n");
+ return sb.toString();
+ }
+
+ boolean hasUpdater = implementations.contains("updater");
+ boolean hasServiceController = implementations.contains("service_controller");
+
+ if (hasUpdater || hasServiceController) {
+ // Generate conditional script with checks
+
+ // Check for updater: single "update" argument
+ if (hasUpdater) {
+ sb.append("# Check if single argument is \"update\"\n");
+ sb.append("if [ \"$#\" -eq 1 ] && [ \"$1\" = \"update\" ]; then\n");
+ sb.append(" exec \"").append(escapedLauncher).append("\" --jdeploy:update\n");
+ sb.append("fi\n\n");
+ }
+
+ // Check for service_controller: first argument is "service"
+ if (hasServiceController) {
+ sb.append("# Check if first argument is \"service\"\n");
+ sb.append("if [ \"$1\" = \"service\" ]; then\n");
+ sb.append(" shift\n");
+ sb.append(" exec \"").append(escapedLauncher).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" --jdeploy:service \"$@\"\n");
+ sb.append("fi\n\n");
+ }
+
+ // Default: normal command
+ sb.append("# Default: normal command\n");
+ sb.append("exec \"").append(escapedLauncher).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" -- \"$@\"\n");
+ } else {
+ // Standard command (no special implementations)
+ sb.append("exec \"").append(escapedLauncher).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" -- \"$@\"\n");
+ }
+
return sb.toString();
}
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java
index 4950b4cf..bc561922 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstaller.java
@@ -1,6 +1,7 @@
package ca.weblite.jdeploy.installer.cli;
import ca.weblite.jdeploy.installer.CliInstallerConstants;
+import ca.weblite.jdeploy.installer.logging.InstallationLogger;
import ca.weblite.jdeploy.installer.models.InstallationSettings;
import ca.weblite.jdeploy.installer.util.DebugLogger;
import ca.weblite.jdeploy.models.CommandSpec;
@@ -77,6 +78,9 @@ public List installCommands(File launcherPath, List commands,
// Add per-app commands directory to PATH
DebugLogger.log("Calling addToPath() for: " + commandsBinDir);
addToPath(commandsBinDir);
+ if (installationLogger != null) {
+ installationLogger.logPathChange(true, commandsBinDir.getAbsolutePath(), "macOS CLI commands");
+ }
}
}
} else {
@@ -107,6 +111,10 @@ public List installCommands(File launcherPath, List commands,
Files.createSymbolicLink(symlinkPath.toPath(), launcherPath.toPath());
System.out.println("Created command-line symlink: " + symlinkPath.getAbsolutePath());
DebugLogger.log("Symlink created successfully: " + symlinkPath);
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.CREATED,
+ symlinkPath.getAbsolutePath(), launcherPath.getAbsolutePath());
+ }
settings.setCommandLineSymlinkCreated(true);
createdFiles.add(symlinkPath);
anyCreated = true;
@@ -115,10 +123,17 @@ public List installCommands(File launcherPath, List commands,
if (commandsBinDir == null) {
DebugLogger.log("Calling addToPath() for: " + launcherBinDir);
addToPath(launcherBinDir);
+ if (installationLogger != null) {
+ installationLogger.logPathChange(true, launcherBinDir.getAbsolutePath(), "macOS CLI launcher");
+ }
}
} catch (Exception e) {
System.err.println("Warning: Failed to create command-line symlink: " + e.getMessage());
DebugLogger.log("Failed to create symlink: " + e.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.FAILED,
+ symlinkPath.getAbsolutePath(), e.getMessage());
+ }
}
}
} else {
@@ -145,23 +160,103 @@ public List installCommands(File launcherPath, List commands,
}
@Override
- protected void writeCommandScript(File scriptPath, String launcherPath, String commandName, List args) throws IOException {
- StringBuilder script = new StringBuilder();
- script.append("#!/bin/bash\n");
- script.append("\"").append(escapeDoubleQuotes(launcherPath)).append("\" ");
- script.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
- script.append(" -- \"$@\"\n");
-
+ protected void writeCommandScript(File scriptPath, String launcherPath, CommandSpec command) throws IOException {
+ String content = generateContent(launcherPath, command);
try (FileOutputStream fos = new FileOutputStream(scriptPath)) {
- fos.write(script.toString().getBytes(StandardCharsets.UTF_8));
+ fos.write(content.getBytes(StandardCharsets.UTF_8));
}
-
scriptPath.setExecutable(true, false);
}
+ /**
+ * Generate the content of a bash script that executes the given launcher with
+ * the configured command name and forwards user-supplied args.
+ * Handles special implementations: updater, launcher, service_controller.
+ *
+ * @param launcherPath Absolute path to the CLI-capable launcher binary.
+ * @param command The command specification including implementations.
+ * @return Script content (including shebang and trailing newline).
+ */
+ private static String generateContent(String launcherPath, CommandSpec command) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("#!/bin/bash\n");
+
+ String escapedLauncher = escapeDoubleQuotes(launcherPath);
+ String commandName = command.getName();
+ List implementations = command.getImplementations();
+
+ // Check for launcher implementation (highest priority, mutually exclusive)
+ if (implementations.contains("launcher")) {
+ // For launcher on macOS: use 'open' command to launch the .app bundle
+ // Extract app bundle path if launcherPath is inside a .app
+ String appPath = extractAppBundlePath(launcherPath);
+ if (appPath != null) {
+ sb.append("exec open -a \"").append(escapeDoubleQuotes(appPath)).append("\" \"$@\"\n");
+ } else {
+ // Fallback: execute binary directly
+ sb.append("exec \"").append(escapedLauncher).append("\" \"$@\"\n");
+ }
+ return sb.toString();
+ }
+
+ boolean hasUpdater = implementations.contains("updater");
+ boolean hasServiceController = implementations.contains("service_controller");
+
+ if (hasUpdater || hasServiceController) {
+ // Generate conditional script with checks
+
+ // Check for updater: single "update" argument
+ if (hasUpdater) {
+ sb.append("# Check if single argument is \"update\"\n");
+ sb.append("if [ \"$#\" -eq 1 ] && [ \"$1\" = \"update\" ]; then\n");
+ sb.append(" exec \"").append(escapedLauncher).append("\" --jdeploy:update\n");
+ sb.append("fi\n\n");
+ }
+
+ // Check for service_controller: first argument is "service"
+ if (hasServiceController) {
+ sb.append("# Check if first argument is \"service\"\n");
+ sb.append("if [ \"$1\" = \"service\" ]; then\n");
+ sb.append(" shift\n");
+ sb.append(" exec \"").append(escapedLauncher).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" --jdeploy:service \"$@\"\n");
+ sb.append("fi\n\n");
+ }
+
+ // Default: normal command
+ sb.append("# Default: normal command\n");
+ sb.append("exec \"").append(escapedLauncher).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" -- \"$@\"\n");
+ } else {
+ // Standard command (no special implementations)
+ sb.append("exec \"").append(escapedLauncher).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" -- \"$@\"\n");
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Extracts the .app bundle path from a launcher path inside a .app bundle.
+ * For example: /Applications/MyApp.app/Contents/MacOS/Client4JLauncher-cli -> /Applications/MyApp.app
+ *
+ * @param launcherPath the launcher path
+ * @return the .app bundle path, or null if not inside a .app bundle
+ */
+ private static String extractAppBundlePath(String launcherPath) {
+ int appIndex = launcherPath.indexOf(".app/");
+ if (appIndex > 0) {
+ return launcherPath.substring(0, appIndex + 4); // Include .app
+ }
+ return null;
+ }
+
/**
* Escapes double quotes in a string for shell script usage.
- *
+ *
* @param s the string to escape
* @return the escaped string
*/
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java
index 90dbcf7e..f58c4417 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/cli/WindowsCliCommandInstaller.java
@@ -1,6 +1,7 @@
package ca.weblite.jdeploy.installer.cli;
import ca.weblite.jdeploy.installer.CliInstallerConstants;
+import ca.weblite.jdeploy.installer.logging.InstallationLogger;
import ca.weblite.jdeploy.installer.models.InstallationSettings;
import ca.weblite.jdeploy.installer.util.CliCommandBinDirResolver;
import ca.weblite.jdeploy.installer.util.ArchitectureUtil;
@@ -31,6 +32,7 @@ public class WindowsCliCommandInstaller implements CliCommandInstaller {
private CollisionHandler collisionHandler = new DefaultCollisionHandler();
private RegistryOperations registryOperations;
+ private InstallationLogger installationLogger;
/**
* Sets the collision handler for detecting and resolving command name conflicts.
@@ -44,13 +46,22 @@ public void setCollisionHandler(CollisionHandler collisionHandler) {
/**
* Sets the registry operations implementation for testing or custom scenarios.
* If not set, JnaRegistryOperations will be used by default.
- *
+ *
* @param registryOperations the RegistryOperations implementation to use
*/
public void setRegistryOperations(RegistryOperations registryOperations) {
this.registryOperations = registryOperations;
}
+ /**
+ * Sets the installation logger for detailed operation logging.
+ *
+ * @param logger the InstallationLogger to use (may be null)
+ */
+ public void setInstallationLogger(InstallationLogger logger) {
+ this.installationLogger = logger;
+ }
+
@Override
public List installCommands(File launcherPath, List commands, InstallationSettings settings) {
List createdFiles = new ArrayList<>();
@@ -135,8 +146,19 @@ public void uninstallCommands(File appDir) {
String cliExeName = metadata.optString(CliInstallerConstants.CLI_EXE_KEY, null);
if (cliExeName != null && !cliExeName.isEmpty()) {
File cliExeFile = new File(appDir, cliExeName);
- if (cliExeFile.exists() && !cliExeFile.delete()) {
- System.err.println("Warning: Failed to delete CLI exe: " + cliExeFile.getAbsolutePath());
+ if (cliExeFile.exists()) {
+ if (cliExeFile.delete()) {
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED,
+ cliExeFile.getAbsolutePath(), "CLI executable");
+ }
+ } else {
+ System.err.println("Warning: Failed to delete CLI exe: " + cliExeFile.getAbsolutePath());
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.FAILED,
+ cliExeFile.getAbsolutePath(), "Failed to delete CLI executable");
+ }
+ }
}
}
@@ -167,8 +189,19 @@ public void uninstallCommands(File appDir) {
for (int i = 0; i < wrappersArray.length(); i++) {
String wrapperName = wrappersArray.getString(i);
File wrapperFile = new File(userBinDir, wrapperName);
- if (wrapperFile.exists() && !wrapperFile.delete()) {
- System.err.println("Warning: Failed to delete wrapper file: " + wrapperFile.getAbsolutePath());
+ if (wrapperFile.exists()) {
+ if (wrapperFile.delete()) {
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(wrapperName, InstallationLogger.FileOperation.DELETED,
+ wrapperFile.getAbsolutePath(), null);
+ }
+ } else {
+ System.err.println("Warning: Failed to delete wrapper file: " + wrapperFile.getAbsolutePath());
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.FAILED,
+ wrapperFile.getAbsolutePath(), "Failed to delete wrapper");
+ }
+ }
}
// Also attempt to delete the extensionless version (for Git Bash)
@@ -177,8 +210,15 @@ public void uninstallCommands(File appDir) {
: wrapperName;
File shWrapperFile = new File(userBinDir, shWrapperName);
- if (shWrapperFile.exists() && !shWrapperFile.delete()) {
- System.err.println("Warning: Failed to delete shell wrapper file: " + shWrapperFile.getAbsolutePath());
+ if (shWrapperFile.exists()) {
+ if (shWrapperFile.delete()) {
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED,
+ shWrapperFile.getAbsolutePath(), "Git Bash wrapper");
+ }
+ } else {
+ System.err.println("Warning: Failed to delete shell wrapper file: " + shWrapperFile.getAbsolutePath());
+ }
}
}
}
@@ -196,8 +236,15 @@ public void uninstallCommands(File appDir) {
}
userBinDir.delete();
System.out.println("Removed per-app bin directory: " + binDirPath);
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED,
+ binDirPath, "Per-app bin directory");
+ }
} catch (Exception e) {
System.err.println("Warning: Failed to remove per-app bin directory: " + e.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logError("Failed to remove per-app bin directory: " + binDirPath, e);
+ }
}
}
}
@@ -208,11 +255,17 @@ public void uninstallCommands(File appDir) {
// Remove from PATH if it was added
if (metadata.optBoolean(CliInstallerConstants.PATH_UPDATED_KEY, false)) {
removeFromPath(perAppPathDir);
+ if (installationLogger != null) {
+ installationLogger.logPathChange(false, perAppPathDir.getAbsolutePath(), "Windows user PATH");
+ }
}
// Remove from Git Bash path if it was added
if (metadata.optBoolean(CliInstallerConstants.GIT_BASH_PATH_UPDATED_KEY, false)) {
removeFromGitBashPath(perAppPathDir);
+ if (installationLogger != null) {
+ installationLogger.logInfo("Removed from Git Bash PATH: " + perAppPathDir.getAbsolutePath());
+ }
}
// Delete manifest file via repository
@@ -220,13 +273,21 @@ public void uninstallCommands(File appDir) {
try {
manifestRepository.delete(packageName, source);
System.out.println("Deleted manifest for package: " + packageName);
+ if (installationLogger != null) {
+ installationLogger.logInfo("Deleted CLI manifest for package: " + packageName);
+ }
} catch (Exception e) {
System.err.println("Warning: Failed to delete manifest: " + e.getMessage());
}
}
// Delete legacy metadata file
- if (!metadataFile.delete()) {
+ if (metadataFile.delete()) {
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED,
+ metadataFile.getAbsolutePath(), "CLI metadata file");
+ }
+ } else {
System.err.println("Warning: Failed to delete metadata file: " + metadataFile.getAbsolutePath());
}
@@ -244,9 +305,20 @@ public boolean addToPath(File binDir) {
try {
// Use InstallWindowsRegistry to manage PATH via registry
InstallWindowsRegistry registry = createRegistryHelper();
- return registry.addToUserPath(binDir);
+ boolean result = registry.addToUserPath(binDir);
+ if (installationLogger != null) {
+ if (result) {
+ installationLogger.logPathChange(true, binDir.getAbsolutePath(), "Windows user PATH");
+ } else {
+ installationLogger.logInfo("PATH already contains: " + binDir.getAbsolutePath());
+ }
+ }
+ return result;
} catch (Exception e) {
System.err.println("Warning: Failed to update user PATH in registry: " + e.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logError("Failed to update user PATH in registry", e);
+ }
return false;
}
}
@@ -286,8 +358,16 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li
if (!binDir.exists()) {
if (!binDir.mkdirs()) {
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.FAILED,
+ binDir.getAbsolutePath(), "Failed to create");
+ }
throw new IOException("Failed to create bin directory: " + binDir.getAbsolutePath());
}
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED,
+ binDir.getAbsolutePath(), "CLI commands bin directory");
+ }
}
for (CommandSpec cs : commands) {
@@ -295,58 +375,210 @@ public List writeCommandWrappersForTest(File binDir, File launcherPath, Li
File cmdWrapper = new File(binDir, name + ".cmd");
File shWrapper = new File(binDir, name);
+ if (installationLogger != null) {
+ installationLogger.logInfo("Processing CLI command: " + name);
+ }
+
// Check for collision with existing wrapper (check .cmd as the primary indicator)
+ boolean wasOverwritten = false;
if (cmdWrapper.exists()) {
String existingLauncherPath = extractLauncherPathFromCmdFile(cmdWrapper);
-
+
if (existingLauncherPath != null && !existingLauncherPath.equals(launcherPath.getAbsolutePath())) {
// Different app owns this command - invoke collision handler
CollisionAction action = collisionHandler.handleCollision(
- name,
- existingLauncherPath,
+ name,
+ existingLauncherPath,
launcherPath.getAbsolutePath()
);
-
+
if (action == CollisionAction.SKIP) {
System.out.println("Skipping command '" + name + "' - already owned by another app");
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(name, InstallationLogger.FileOperation.SKIPPED_COLLISION,
+ cmdWrapper.getAbsolutePath(),
+ "Owned by: " + existingLauncherPath);
+ }
continue;
}
// OVERWRITE - fall through to delete and recreate
System.out.println("Overwriting command '" + name + "' from another app");
+ if (installationLogger != null) {
+ installationLogger.logInfo("Overwriting command from different app: " + existingLauncherPath);
+ }
}
// Same app or couldn't parse - silently overwrite
+ wasOverwritten = true;
cmdWrapper.delete();
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED,
+ cmdWrapper.getAbsolutePath(), "Existing wrapper (same app update)");
+ }
}
if (shWrapper.exists()) {
shWrapper.delete();
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(InstallationLogger.FileOperation.DELETED,
+ shWrapper.getAbsolutePath(), "Existing shell wrapper");
+ }
}
- // 1. Windows batch wrapper (.cmd): invoke the launcher with --jdeploy:command= and forward all args
- // We use \r\n for Windows batch files
- String cmdContent = "@echo off\r\n\"" + launcherPath.getAbsolutePath() + "\" " +
- CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX + name + " -- %*\r\n";
+ // Generate wrapper content based on implementations
+ String cmdContent = generateCmdContent(launcherPath, cs);
+ String shContent = generateShellContent(launcherPath, cs);
FileUtils.writeStringToFile(cmdWrapper, cmdContent, "UTF-8");
cmdWrapper.setExecutable(true, false);
created.add(cmdWrapper);
+ if (installationLogger != null) {
+ installationLogger.logCliCommand(name,
+ wasOverwritten ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED,
+ cmdWrapper.getAbsolutePath(),
+ "Launcher: " + launcherPath.getAbsolutePath());
+ }
- // 2. Extensionless shell script for Git Bash / MSYS2
- // We use \n for shell scripts
- String msysLauncherPath = convertToMsysPath(launcherPath);
- String shContent = "#!/bin/sh\n\"" + msysLauncherPath + "\" " +
- CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX + name + " -- \"$@\"\n";
-
FileUtils.writeStringToFile(shWrapper, shContent, "UTF-8");
shWrapper.setExecutable(true, false);
+ if (installationLogger != null) {
+ installationLogger.logFileOperation(
+ wasOverwritten ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED,
+ shWrapper.getAbsolutePath(), "Git Bash wrapper for " + name);
+ }
}
return created;
}
+ /**
+ * Generates Windows batch (.cmd) wrapper content based on command implementations.
+ *
+ * @param launcherPath the path to the launcher executable
+ * @param command the command specification including implementations
+ * @return the .cmd file content with \r\n line endings
+ */
+ private String generateCmdContent(File launcherPath, CommandSpec command) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("@echo off\r\n");
+
+ String launcherPathStr = launcherPath.getAbsolutePath();
+ String commandName = command.getName();
+ List implementations = command.getImplementations();
+
+ // Check for launcher implementation (highest priority, mutually exclusive)
+ if (implementations.contains("launcher")) {
+ // For launcher: just execute the binary directly with all args
+ sb.append("\"").append(launcherPathStr).append("\" %*\r\n");
+ return sb.toString();
+ }
+
+ boolean hasUpdater = implementations.contains("updater");
+ boolean hasServiceController = implementations.contains("service_controller");
+
+ if (hasUpdater || hasServiceController) {
+ // Generate conditional batch script
+
+ // Check for updater: single "update" argument
+ if (hasUpdater) {
+ sb.append("REM Check if single argument is \"update\"\r\n");
+ sb.append("if \"%~1\"==\"update\" if \"%~2\"==\"\" (\r\n");
+ sb.append(" \"").append(launcherPathStr).append("\" --jdeploy:update\r\n");
+ sb.append(" goto :eof\r\n");
+ sb.append(")\r\n\r\n");
+ }
+
+ // Check for service_controller: first argument is "service"
+ if (hasServiceController) {
+ sb.append("REM Check if first argument is \"service\"\r\n");
+ sb.append("if \"%~1\"==\"service\" (\r\n");
+ sb.append(" shift\r\n");
+ sb.append(" \"").append(launcherPathStr).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" --jdeploy:service %*\r\n");
+ sb.append(" goto :eof\r\n");
+ sb.append(")\r\n\r\n");
+ }
+
+ // Default: normal command
+ sb.append("REM Default: normal command\r\n");
+ sb.append("\"").append(launcherPathStr).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" -- %*\r\n");
+ } else {
+ // Standard command (no special implementations)
+ sb.append("\"").append(launcherPathStr).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" -- %*\r\n");
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Generates shell script wrapper content for Git Bash / MSYS2 based on command implementations.
+ *
+ * @param launcherPath the path to the launcher executable
+ * @param command the command specification including implementations
+ * @return the shell script content with \n line endings
+ */
+ private String generateShellContent(File launcherPath, CommandSpec command) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("#!/bin/sh\n");
+
+ String msysLauncherPath = convertToMsysPath(launcherPath);
+ String commandName = command.getName();
+ List implementations = command.getImplementations();
+
+ // Check for launcher implementation (highest priority, mutually exclusive)
+ if (implementations.contains("launcher")) {
+ // For launcher: just execute the binary directly with all args
+ sb.append("exec \"").append(msysLauncherPath).append("\" \"$@\"\n");
+ return sb.toString();
+ }
+
+ boolean hasUpdater = implementations.contains("updater");
+ boolean hasServiceController = implementations.contains("service_controller");
+
+ if (hasUpdater || hasServiceController) {
+ // Generate conditional shell script
+
+ // Check for updater: single "update" argument
+ if (hasUpdater) {
+ sb.append("# Check if single argument is \"update\"\n");
+ sb.append("if [ \"$#\" -eq 1 ] && [ \"$1\" = \"update\" ]; then\n");
+ sb.append(" exec \"").append(msysLauncherPath).append("\" --jdeploy:update\n");
+ sb.append("fi\n\n");
+ }
+
+ // Check for service_controller: first argument is "service"
+ if (hasServiceController) {
+ sb.append("# Check if first argument is \"service\"\n");
+ sb.append("if [ \"$1\" = \"service\" ]; then\n");
+ sb.append(" shift\n");
+ sb.append(" exec \"").append(msysLauncherPath).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" --jdeploy:service \"$@\"\n");
+ sb.append("fi\n\n");
+ }
+
+ // Default: normal command
+ sb.append("# Default: normal command\n");
+ sb.append("exec \"").append(msysLauncherPath).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" -- \"$@\"\n");
+ } else {
+ // Standard command (no special implementations)
+ sb.append("exec \"").append(msysLauncherPath).append("\" ");
+ sb.append(CliInstallerConstants.JDEPLOY_COMMAND_ARG_PREFIX).append(commandName);
+ sb.append(" -- \"$@\"\n");
+ }
+
+ return sb.toString();
+ }
+
/**
* Extracts the launcher path from an existing .cmd wrapper file.
* Parses the file looking for the pattern: "path\to\launcher.exe" --jdeploy:command=
- *
+ *
* @param cmdFile the path to the existing .cmd file
* @return the launcher path if found, or null if parsing fails
*/
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/logging/InstallationLogger.java b/installer/src/main/java/ca/weblite/jdeploy/installer/logging/InstallationLogger.java
new file mode 100644
index 00000000..a638c613
--- /dev/null
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/logging/InstallationLogger.java
@@ -0,0 +1,377 @@
+package ca.weblite.jdeploy.installer.logging;
+
+import java.io.*;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Comprehensive logger for installation and uninstallation operations.
+ * Records all file operations, registry changes, and other installation activities
+ * to a dedicated log file for debugging and auditing purposes.
+ *
+ * Log files are stored at:
+ * - Installs: ~/.jdeploy/log/jdeploy-installer/installs/{packageName}-{timestamp}.log
+ * - Uninstalls: ~/.jdeploy/log/jdeploy-installer/uninstalls/{packageName}-{timestamp}.log
+ */
+public class InstallationLogger implements Closeable {
+
+ public enum OperationType {
+ INSTALL,
+ UNINSTALL
+ }
+
+ public enum FileOperation {
+ CREATED,
+ DELETED,
+ OVERWRITTEN,
+ SKIPPED_EXISTS,
+ SKIPPED_COLLISION,
+ COPIED,
+ MOVED,
+ MODIFIED,
+ FAILED
+ }
+
+ public enum RegistryOperation {
+ KEY_CREATED,
+ KEY_DELETED,
+ VALUE_SET,
+ VALUE_DELETED,
+ PATH_ADDED,
+ PATH_REMOVED,
+ FAILED
+ }
+
+ public enum DirectoryOperation {
+ CREATED,
+ DELETED,
+ SKIPPED_EXISTS,
+ SKIPPED_NOT_EMPTY,
+ FAILED
+ }
+
+ private static final SimpleDateFormat TIMESTAMP_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
+ private static final SimpleDateFormat FILE_TIMESTAMP_FORMAT = new SimpleDateFormat("yyyyMMdd-HHmmss");
+
+ private final PrintWriter writer;
+ private final File logFile;
+ private final String packageName;
+ private final OperationType operationType;
+ private final long startTime;
+
+ private int filesCreated = 0;
+ private int filesDeleted = 0;
+ private int filesOverwritten = 0;
+ private int filesSkipped = 0;
+ private int filesFailed = 0;
+ private int registryOperations = 0;
+ private int directoriesCreated = 0;
+ private int directoriesDeleted = 0;
+
+ /**
+ * Creates a new installation logger.
+ *
+ * @param packageName the fully qualified package name
+ * @param operationType whether this is an install or uninstall operation
+ * @throws IOException if the log file cannot be created
+ */
+ public InstallationLogger(String packageName, OperationType operationType) throws IOException {
+ this.packageName = packageName;
+ this.operationType = operationType;
+ this.startTime = System.currentTimeMillis();
+
+ String userHome = System.getProperty("user.home");
+ String subDir = operationType == OperationType.INSTALL ? "installs" : "uninstalls";
+ File logDir = new File(userHome, ".jdeploy" + File.separator + "log" +
+ File.separator + "jdeploy-installer" + File.separator + subDir);
+
+ if (!logDir.exists() && !logDir.mkdirs()) {
+ throw new IOException("Failed to create log directory: " + logDir.getAbsolutePath());
+ }
+
+ String timestamp = FILE_TIMESTAMP_FORMAT.format(new Date());
+ String safePackageName = packageName.replaceAll("[^a-zA-Z0-9.-]", "_");
+ this.logFile = new File(logDir, safePackageName + "-" + timestamp + ".log");
+
+ this.writer = new PrintWriter(new BufferedWriter(new FileWriter(logFile)), true);
+
+ writeHeader();
+ }
+
+ private void writeHeader() {
+ writeLine("================================================================================");
+ writeLine(operationType == OperationType.INSTALL ? "INSTALLATION LOG" : "UNINSTALLATION LOG");
+ writeLine("================================================================================");
+ writeLine("Package: " + packageName);
+ writeLine("Started: " + TIMESTAMP_FORMAT.format(new Date(startTime)));
+ writeLine("Log File: " + logFile.getAbsolutePath());
+ writeLine("--------------------------------------------------------------------------------");
+ writeLine("");
+ }
+
+ private synchronized void writeLine(String message) {
+ writer.println(message);
+ }
+
+ private synchronized void writeTimestampedLine(String message) {
+ String timestamp = TIMESTAMP_FORMAT.format(new Date());
+ writer.println("[" + timestamp + "] " + message);
+ }
+
+ /**
+ * Logs the start of a major section.
+ */
+ public void logSection(String sectionName) {
+ writeLine("");
+ writeLine("--- " + sectionName + " ---");
+ }
+
+ /**
+ * Logs a file operation.
+ */
+ public void logFileOperation(FileOperation operation, String filePath, String details) {
+ String prefix = getFileOperationPrefix(operation);
+ String message = prefix + " " + filePath;
+ if (details != null && !details.isEmpty()) {
+ message += " (" + details + ")";
+ }
+ writeTimestampedLine(message);
+
+ // Update counters
+ switch (operation) {
+ case CREATED:
+ case COPIED:
+ filesCreated++;
+ break;
+ case DELETED:
+ filesDeleted++;
+ break;
+ case OVERWRITTEN:
+ case MODIFIED:
+ filesOverwritten++;
+ break;
+ case SKIPPED_EXISTS:
+ case SKIPPED_COLLISION:
+ filesSkipped++;
+ break;
+ case FAILED:
+ filesFailed++;
+ break;
+ }
+ }
+
+ /**
+ * Logs a file operation without additional details.
+ */
+ public void logFileOperation(FileOperation operation, String filePath) {
+ logFileOperation(operation, filePath, null);
+ }
+
+ private String getFileOperationPrefix(FileOperation operation) {
+ switch (operation) {
+ case CREATED: return "[FILE CREATED]";
+ case DELETED: return "[FILE DELETED]";
+ case OVERWRITTEN: return "[FILE OVERWRITTEN]";
+ case SKIPPED_EXISTS: return "[FILE SKIPPED - EXISTS]";
+ case SKIPPED_COLLISION: return "[FILE SKIPPED - COLLISION]";
+ case COPIED: return "[FILE COPIED]";
+ case MOVED: return "[FILE MOVED]";
+ case MODIFIED: return "[FILE MODIFIED]";
+ case FAILED: return "[FILE FAILED]";
+ default: return "[FILE]";
+ }
+ }
+
+ /**
+ * Logs a directory operation.
+ */
+ public void logDirectoryOperation(DirectoryOperation operation, String dirPath, String details) {
+ String prefix = getDirectoryOperationPrefix(operation);
+ String message = prefix + " " + dirPath;
+ if (details != null && !details.isEmpty()) {
+ message += " (" + details + ")";
+ }
+ writeTimestampedLine(message);
+
+ // Update counters
+ switch (operation) {
+ case CREATED:
+ directoriesCreated++;
+ break;
+ case DELETED:
+ directoriesDeleted++;
+ break;
+ }
+ }
+
+ /**
+ * Logs a directory operation without additional details.
+ */
+ public void logDirectoryOperation(DirectoryOperation operation, String dirPath) {
+ logDirectoryOperation(operation, dirPath, null);
+ }
+
+ private String getDirectoryOperationPrefix(DirectoryOperation operation) {
+ switch (operation) {
+ case CREATED: return "[DIR CREATED]";
+ case DELETED: return "[DIR DELETED]";
+ case SKIPPED_EXISTS: return "[DIR SKIPPED - EXISTS]";
+ case SKIPPED_NOT_EMPTY: return "[DIR SKIPPED - NOT EMPTY]";
+ case FAILED: return "[DIR FAILED]";
+ default: return "[DIR]";
+ }
+ }
+
+ /**
+ * Logs a registry operation.
+ */
+ public void logRegistryOperation(RegistryOperation operation, String registryPath, String details) {
+ String prefix = getRegistryOperationPrefix(operation);
+ String message = prefix + " " + registryPath;
+ if (details != null && !details.isEmpty()) {
+ message += " (" + details + ")";
+ }
+ writeTimestampedLine(message);
+
+ if (operation != RegistryOperation.FAILED) {
+ registryOperations++;
+ }
+ }
+
+ /**
+ * Logs a registry operation without additional details.
+ */
+ public void logRegistryOperation(RegistryOperation operation, String registryPath) {
+ logRegistryOperation(operation, registryPath, null);
+ }
+
+ private String getRegistryOperationPrefix(RegistryOperation operation) {
+ switch (operation) {
+ case KEY_CREATED: return "[REGISTRY KEY CREATED]";
+ case KEY_DELETED: return "[REGISTRY KEY DELETED]";
+ case VALUE_SET: return "[REGISTRY VALUE SET]";
+ case VALUE_DELETED: return "[REGISTRY VALUE DELETED]";
+ case PATH_ADDED: return "[REGISTRY PATH ADDED]";
+ case PATH_REMOVED: return "[REGISTRY PATH REMOVED]";
+ case FAILED: return "[REGISTRY FAILED]";
+ default: return "[REGISTRY]";
+ }
+ }
+
+ /**
+ * Logs a CLI command wrapper operation.
+ */
+ public void logCliCommand(String commandName, FileOperation operation, String wrapperPath, String details) {
+ String prefix = "[CLI COMMAND " + operation.name() + "]";
+ String message = prefix + " " + commandName + " -> " + wrapperPath;
+ if (details != null && !details.isEmpty()) {
+ message += " (" + details + ")";
+ }
+ writeTimestampedLine(message);
+ }
+
+ /**
+ * Logs a shortcut operation.
+ */
+ public void logShortcut(FileOperation operation, String shortcutPath, String targetPath) {
+ String prefix = "[SHORTCUT " + operation.name() + "]";
+ String message = prefix + " " + shortcutPath;
+ if (targetPath != null) {
+ message += " -> " + targetPath;
+ }
+ writeTimestampedLine(message);
+ }
+
+ /**
+ * Logs an informational message.
+ */
+ public void logInfo(String message) {
+ writeTimestampedLine("[INFO] " + message);
+ }
+
+ /**
+ * Logs a warning message.
+ */
+ public void logWarning(String message) {
+ writeTimestampedLine("[WARNING] " + message);
+ }
+
+ /**
+ * Logs an error message.
+ */
+ public void logError(String message) {
+ writeTimestampedLine("[ERROR] " + message);
+ }
+
+ /**
+ * Logs an error with exception details.
+ */
+ public void logError(String message, Throwable ex) {
+ writeTimestampedLine("[ERROR] " + message + ": " + ex.getMessage());
+ ex.printStackTrace(writer);
+ }
+
+ /**
+ * Logs PATH environment variable changes.
+ */
+ public void logPathChange(boolean added, String pathEntry, String context) {
+ String action = added ? "ADDED TO" : "REMOVED FROM";
+ String message = "[PATH " + action + "] " + pathEntry;
+ if (context != null) {
+ message += " (" + context + ")";
+ }
+ writeTimestampedLine(message);
+ }
+
+ /**
+ * Gets the log file path.
+ */
+ public File getLogFile() {
+ return logFile;
+ }
+
+ /**
+ * Writes a summary and closes the logger.
+ */
+ @Override
+ public void close() {
+ long endTime = System.currentTimeMillis();
+ long duration = endTime - startTime;
+
+ writeLine("");
+ writeLine("--------------------------------------------------------------------------------");
+ writeLine("SUMMARY");
+ writeLine("--------------------------------------------------------------------------------");
+ writeLine("Completed: " + TIMESTAMP_FORMAT.format(new Date(endTime)));
+ writeLine("Duration: " + formatDuration(duration));
+ writeLine("");
+ writeLine("Files created: " + filesCreated);
+ writeLine("Files deleted: " + filesDeleted);
+ writeLine("Files overwritten: " + filesOverwritten);
+ writeLine("Files skipped: " + filesSkipped);
+ writeLine("Files failed: " + filesFailed);
+ writeLine("Directories created: " + directoriesCreated);
+ writeLine("Directories deleted: " + directoriesDeleted);
+ writeLine("Registry operations: " + registryOperations);
+ writeLine("");
+ writeLine("================================================================================");
+ writeLine("END OF LOG");
+ writeLine("================================================================================");
+
+ writer.close();
+
+ // Also print log file location to stdout for visibility
+ System.out.println("Installation log written to: " + logFile.getAbsolutePath());
+ }
+
+ private String formatDuration(long millis) {
+ long seconds = millis / 1000;
+ long ms = millis % 1000;
+ if (seconds < 60) {
+ return seconds + "." + String.format("%03d", ms) + " seconds";
+ }
+ long minutes = seconds / 60;
+ seconds = seconds % 60;
+ return minutes + " minutes, " + seconds + " seconds";
+ }
+}
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindows.java b/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindows.java
index d5d11278..d13bb80e 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindows.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindows.java
@@ -6,6 +6,7 @@
import ca.weblite.jdeploy.installer.Main;
import ca.weblite.jdeploy.installer.cli.CollisionHandler;
import ca.weblite.jdeploy.installer.cli.UIAwareCollisionHandler;
+import ca.weblite.jdeploy.installer.logging.InstallationLogger;
import ca.weblite.jdeploy.installer.models.InstallationSettings;
import ca.weblite.jdeploy.installer.uninstall.UninstallManifestBuilder;
import ca.weblite.jdeploy.installer.uninstall.UninstallManifestWriter;
@@ -52,6 +53,32 @@ public File install(
File tmpBundles,
ca.weblite.jdeploy.installer.npm.NPMPackageVersion npmPackageVersion,
CollisionHandler collisionHandler
+ ) throws Exception {
+ // Create installation logger
+ InstallationLogger logger = null;
+ try {
+ logger = new InstallationLogger(fullyQualifiedPackageName, InstallationLogger.OperationType.INSTALL);
+ } catch (IOException e) {
+ System.err.println("Warning: Failed to create installation logger: " + e.getMessage());
+ }
+
+ try {
+ return doInstall(context, installationSettings, fullyQualifiedPackageName, tmpBundles, npmPackageVersion, collisionHandler, logger);
+ } finally {
+ if (logger != null) {
+ logger.close();
+ }
+ }
+ }
+
+ private File doInstall(
+ InstallationContext context,
+ InstallationSettings installationSettings,
+ String fullyQualifiedPackageName,
+ File tmpBundles,
+ ca.weblite.jdeploy.installer.npm.NPMPackageVersion npmPackageVersion,
+ CollisionHandler collisionHandler,
+ InstallationLogger logger
) throws Exception {
AppInfo appInfo = installationSettings.getAppInfo();
File tmpExePath = findTmpExeFile(tmpBundles);
@@ -60,7 +87,20 @@ public File install(
File jdeployHome = new File(userHome, ".jdeploy");
File appsDir = new File(jdeployHome, "apps");
File appDir = new File(appsDir, fullyQualifiedPackageName);
+
+ if (logger != null) {
+ logger.logInfo("Starting installation of " + appInfo.getTitle() + " version " + appInfo.getNpmVersion());
+ logger.logInfo("Package: " + appInfo.getNpmPackage());
+ logger.logInfo("Source: " + (appInfo.getNpmSource() != null ? appInfo.getNpmSource() : "NPM"));
+ logger.logSection("Creating Application Directory");
+ }
+
+ boolean appDirCreated = !appDir.exists();
appDir.mkdirs();
+ if (logger != null && appDirCreated) {
+ logger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, appDir.getAbsolutePath());
+ }
+
String nameSuffix = "";
if (appInfo.getNpmVersion().startsWith("0.0.0-")) {
@@ -79,17 +119,39 @@ public File install(
File cliExePath = null;
try {
+ if (logger != null) {
+ logger.logSection("Installing Application Executable");
+ }
+ boolean exeExisted = exePath.exists();
FileUtil.copy(tmpExePath, exePath);
exePath.setExecutable(true, false);
-
+ if (logger != null) {
+ logger.logFileOperation(
+ exeExisted ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED,
+ exePath.getAbsolutePath(),
+ "Main application executable"
+ );
+ }
+
// Copy CLI launcher if CLI commands will be installed
if (installationSettings.isInstallCliCommands()) {
cliExePath = new File(exePath.getParentFile(),
exePath.getName().replace(".exe", CliInstallerConstants.CLI_LAUNCHER_SUFFIX + ".exe"));
+ boolean cliExeExisted = cliExePath.exists();
WindowsPESubsystemModifier.copyAndModifySubsystem(exePath, cliExePath);
cliExePath.setExecutable(true, false);
+ if (logger != null) {
+ logger.logFileOperation(
+ cliExeExisted ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED,
+ cliExePath.getAbsolutePath(),
+ "CLI launcher executable"
+ );
+ }
}
} catch (IOException e) {
+ if (logger != null) {
+ logger.logError("Failed to copy application executable", e);
+ }
String logPath = System.getProperty("user.home") + "\\.jdeploy\\log\\jdeploy-installer.log";
String technicalMessage = "Failed to copy application executable to " + exePath.getAbsolutePath() + ": " + e.getMessage();
String userMessage = "" +
@@ -108,25 +170,45 @@ public File install(
}
// Copy the icon.png if it is present
+ if (logger != null) {
+ logger.logSection("Installing Application Icons");
+ }
File bundleIcon = new File(appXmlFile.getParentFile(), "icon.png");
File iconPath = new File(appDir, "icon.png");
File icoPath = new File(appDir, "icon.ico");
if (Files.exists(bundleIcon.toPath())) {
+ boolean iconExisted = iconPath.exists();
FileUtil.copy(bundleIcon, iconPath);
+ if (logger != null) {
+ logger.logFileOperation(
+ iconExisted ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED,
+ iconPath.getAbsolutePath(),
+ "Application icon (PNG)"
+ );
+ }
}
+
List shortcutFiles = installWindowsLinks(
appInfo,
installationSettings,
exePath,
appDir,
- appInfo.getTitle() + nameSuffix
+ appInfo.getTitle() + nameSuffix,
+ logger
);
+ if (logger != null) {
+ logger.logSection("Registry Operations");
+ }
+
File registryBackupLogs = new File(appDir, "registry-backup-logs");
if (!registryBackupLogs.exists()) {
registryBackupLogs.mkdirs();
+ if (logger != null) {
+ logger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, registryBackupLogs.getAbsolutePath());
+ }
}
int nextLogIndex = 0;
for (File child : registryBackupLogs.listFiles()) {
@@ -142,25 +224,41 @@ public File install(
}
File registryBackupLog = new File(registryBackupLogs, nextLogIndex+".reg");
try (FileOutputStream fos = new FileOutputStream(registryBackupLog)) {
- InstallWindowsRegistry registryInstaller = new InstallWindowsRegistry(appInfo, exePath, icoPath, fos);
+ InstallWindowsRegistry registryInstaller = new InstallWindowsRegistry(appInfo, exePath, icoPath, fos, logger);
registryInstaller.register();
// Install CLI commands if user requested
List commands = npmPackageVersion != null ? npmPackageVersion.getCommands() : null;
List cliWrapperFiles = null;
if (installationSettings.isInstallCliCommands() && commands != null && !commands.isEmpty()) {
- ca.weblite.jdeploy.installer.cli.WindowsCliCommandInstaller cliInstaller =
+ if (logger != null) {
+ logger.logSection("Installing CLI Commands");
+ logger.logInfo("Installing " + commands.size() + " CLI command(s)");
+ }
+ ca.weblite.jdeploy.installer.cli.WindowsCliCommandInstaller cliInstaller =
new ca.weblite.jdeploy.installer.cli.WindowsCliCommandInstaller();
cliInstaller.setCollisionHandler(collisionHandler);
+ cliInstaller.setInstallationLogger(logger);
File launcherForCommands = cliExePath != null ? cliExePath : exePath;
cliWrapperFiles = cliInstaller.installCommands(launcherForCommands, commands, installationSettings);
+ if (logger != null) {
+ logger.logInfo("CLI commands installation complete: " + (cliWrapperFiles != null ? cliWrapperFiles.size() : 0) + " wrapper(s) created");
+ }
}
//Try to copy the uninstaller
+ if (logger != null) {
+ logger.logSection("Installing Uninstaller");
+ }
File uninstallerPath = registryInstaller.getUninstallerPath();
+ boolean uninstallerDirCreated = !uninstallerPath.getParentFile().exists();
uninstallerPath.getParentFile().mkdirs();
+ if (logger != null && uninstallerDirCreated) {
+ logger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, uninstallerPath.getParentFile().getAbsolutePath());
+ }
File installerExePath = new File(System.getProperty("client4j.launcher.path"));
- if (uninstallerPath.exists()) {
+ boolean uninstallerExisted = uninstallerPath.exists();
+ if (uninstallerExisted) {
if (!uninstallerPath.canWrite()) {
uninstallerPath.setWritable(true, false);
try {
@@ -171,8 +269,14 @@ public File install(
}
}
if (!uninstallerPath.delete()) {
+ if (logger != null) {
+ logger.logFileOperation(InstallationLogger.FileOperation.FAILED, uninstallerPath.getAbsolutePath(), "Failed to delete existing uninstaller");
+ }
throw new IOException("Failed to delete uninstaller: "+uninstallerPath);
}
+ if (logger != null) {
+ logger.logFileOperation(InstallationLogger.FileOperation.DELETED, uninstallerPath.getAbsolutePath(), "Existing uninstaller");
+ }
try {
// Give Windows time to update its cache
@@ -183,13 +287,14 @@ public File install(
}
FileUtils.copyFile(installerExePath, uninstallerPath);
uninstallerPath.setExecutable(true, false);
- FileUtils.copyDirectory(
- context.findInstallFilesDir(),
- new File(
- uninstallerPath.getParentFile(),
- context.findInstallFilesDir().getName()
- )
- );
+ if (logger != null) {
+ logger.logFileOperation(InstallationLogger.FileOperation.CREATED, uninstallerPath.getAbsolutePath(), "Uninstaller executable");
+ }
+ File jdeployFilesDestDir = new File(uninstallerPath.getParentFile(), context.findInstallFilesDir().getName());
+ FileUtils.copyDirectory(context.findInstallFilesDir(), jdeployFilesDestDir);
+ if (logger != null) {
+ logger.logDirectoryOperation(InstallationLogger.DirectoryOperation.CREATED, jdeployFilesDestDir.getAbsolutePath(), "jDeploy files for uninstaller");
+ }
// Build and write uninstall manifest
try {
@@ -339,37 +444,65 @@ private List installWindowsLinks(
InstallationSettings installationSettings,
File exePath,
File appDir,
- String appTitle
+ String appTitle,
+ InstallationLogger logger
) throws Exception {
+ if (logger != null) {
+ logger.logSection("Creating Windows Shortcuts");
+ }
List shortcutFiles = new ArrayList<>();
File pngIconPath = new File(appDir, "icon.png");
File icoPath = new File(appDir.getCanonicalFile(), "icon.ico");
if (!Files.exists(pngIconPath.toPath())) {
copyResourceToFile(Main.class, "icon.png", pngIconPath);
+ if (logger != null) {
+ logger.logFileOperation(InstallationLogger.FileOperation.CREATED, pngIconPath.getAbsolutePath(), "Default icon (PNG)");
+ }
}
if (!Files.exists(pngIconPath.toPath())) {
throw new IOException("Failed to create the .ico file for some reason. "+icoPath);
}
+ boolean icoExisted = icoPath.exists();
convertWindowsIcon(pngIconPath.getCanonicalFile(), icoPath);
+ if (logger != null) {
+ logger.logFileOperation(
+ icoExisted ? InstallationLogger.FileOperation.OVERWRITTEN : InstallationLogger.FileOperation.CREATED,
+ icoPath.getAbsolutePath(),
+ "Windows icon (ICO)"
+ );
+ }
if (installationSettings.isAddToDesktop()) {
try {
if (appInfo.isRequireRunAsAdmin()) {
File shortcut = installWindowsLink(appInfo, ShellLink.DESKTOP, exePath, icoPath, appTitle, true);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
} else if (appInfo.isAllowRunAsAdmin()) {
File shortcut = installWindowsLink(appInfo, ShellLink.DESKTOP, exePath, icoPath, appTitle, false);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
File shortcutAdmin = installWindowsLink(appInfo, ShellLink.DESKTOP, exePath, icoPath, appTitle + RUN_AS_ADMIN_SUFFIX, true);
- if (shortcutAdmin != null) shortcutFiles.add(shortcutAdmin);
+ if (shortcutAdmin != null) {
+ shortcutFiles.add(shortcutAdmin);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcutAdmin.getAbsolutePath(), exePath.getAbsolutePath());
+ }
} else {
File shortcut = installWindowsLink(appInfo, ShellLink.DESKTOP, exePath, icoPath, appTitle, false);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
}
} catch (Exception ex) {
+ if (logger != null) logger.logError("Failed to install desktop shortcut", ex);
throw new RuntimeException("Failed to install desktop shortcut", ex);
}
}
@@ -377,18 +510,31 @@ private List installWindowsLinks(
try {
if (appInfo.isRequireRunAsAdmin()) {
File shortcut = installWindowsLink(appInfo, ShellLink.PROGRAM_MENU, exePath, icoPath, appTitle, true);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
} else if (appInfo.isAllowRunAsAdmin()) {
File shortcut = installWindowsLink(appInfo, ShellLink.PROGRAM_MENU, exePath, icoPath, appTitle, false);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
File shortcutAdmin = installWindowsLink(appInfo, ShellLink.PROGRAM_MENU, exePath, icoPath, appTitle + RUN_AS_ADMIN_SUFFIX, true);
- if (shortcutAdmin != null) shortcutFiles.add(shortcutAdmin);
+ if (shortcutAdmin != null) {
+ shortcutFiles.add(shortcutAdmin);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcutAdmin.getAbsolutePath(), exePath.getAbsolutePath());
+ }
} else {
File shortcut = installWindowsLink(appInfo, ShellLink.PROGRAM_MENU, exePath, icoPath, appTitle, false);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
}
} catch (Exception ex) {
+ if (logger != null) logger.logError("Failed to install program menu shortcut", ex);
throw new RuntimeException("Failed to install program menu shortcut", ex);
}
@@ -397,21 +543,34 @@ private List installWindowsLinks(
try {
if (appInfo.isRequireRunAsAdmin()) {
File shortcut = installWindowsLink(appInfo, ShellLink.START_MENU, exePath, icoPath, appTitle, true);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
} else if (appInfo.isAllowRunAsAdmin()) {
File shortcut = installWindowsLink(appInfo, ShellLink.START_MENU, exePath, icoPath, appTitle, false);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
File shortcutAdmin = installWindowsLink(appInfo, ShellLink.START_MENU, exePath, icoPath, appTitle + RUN_AS_ADMIN_SUFFIX, true);
- if (shortcutAdmin != null) shortcutFiles.add(shortcutAdmin);
+ if (shortcutAdmin != null) {
+ shortcutFiles.add(shortcutAdmin);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcutAdmin.getAbsolutePath(), exePath.getAbsolutePath());
+ }
} else {
File shortcut = installWindowsLink(appInfo, ShellLink.START_MENU, exePath, icoPath, appTitle, false);
- if (shortcut != null) shortcutFiles.add(shortcut);
+ if (shortcut != null) {
+ shortcutFiles.add(shortcut);
+ if (logger != null) logger.logShortcut(InstallationLogger.FileOperation.CREATED, shortcut.getAbsolutePath(), exePath.getAbsolutePath());
+ }
}
} catch (Exception ex) {
+ if (logger != null) logger.logError("Failed to install start menu shortcut", ex);
throw new RuntimeException("Failed to install start menu shortcut", ex);
}
}
-
+
return shortcutFiles;
}
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindowsRegistry.java b/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindowsRegistry.java
index 0381176e..ee32b877 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindowsRegistry.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/InstallWindowsRegistry.java
@@ -1,6 +1,7 @@
package ca.weblite.jdeploy.installer.win;
import ca.weblite.jdeploy.app.AppInfo;
+import ca.weblite.jdeploy.installer.logging.InstallationLogger;
import ca.weblite.jdeploy.installer.models.InstallationSettings;
import ca.weblite.tools.io.MD5;
import org.apache.commons.io.FileUtils;
@@ -23,6 +24,7 @@ public class InstallWindowsRegistry {
private File icon, exe;
private RegistryOperations registryOps;
private boolean skipWinRegistryOperations = false;
+ private InstallationLogger installationLogger;
private static final Set contactExtensions = new HashSet<>();
private static final Set mediaExtensions = new HashSet<>();
@@ -191,7 +193,17 @@ public InstallWindowsRegistry(
File icon,
OutputStream backupLog
) {
- this(appInfo, exe, icon, backupLog, new JnaRegistryOperations());
+ this(appInfo, exe, icon, backupLog, new JnaRegistryOperations(), null);
+ }
+
+ public InstallWindowsRegistry(
+ AppInfo appInfo,
+ File exe,
+ File icon,
+ OutputStream backupLog,
+ InstallationLogger logger
+ ) {
+ this(appInfo, exe, icon, backupLog, new JnaRegistryOperations(), logger);
}
public InstallWindowsRegistry(
@@ -200,11 +212,23 @@ public InstallWindowsRegistry(
File icon,
OutputStream backupLog,
RegistryOperations registryOps
+ ) {
+ this(appInfo, exe, icon, backupLog, registryOps, null);
+ }
+
+ public InstallWindowsRegistry(
+ AppInfo appInfo,
+ File exe,
+ File icon,
+ OutputStream backupLog,
+ RegistryOperations registryOps,
+ InstallationLogger logger
) {
this.appInfo = appInfo;
this.exe = exe;
this.icon = icon;
this.registryOps = registryOps;
+ this.installationLogger = logger;
if (backupLog != null) {
this.backupLog = backupLog;
this.backupLogOut = new PrintStream(backupLog);
@@ -219,6 +243,27 @@ public void setSkipWinRegistryOperations(boolean skip) {
this.skipWinRegistryOperations = skip;
}
+ /**
+ * Helper method to log a registry key creation.
+ */
+ private void logRegistryKeyCreated(String keyPath) {
+ if (installationLogger != null) {
+ installationLogger.logRegistryOperation(InstallationLogger.RegistryOperation.KEY_CREATED,
+ "HKCU\\" + keyPath);
+ }
+ }
+
+ /**
+ * Helper method to log a registry value set.
+ */
+ private void logRegistryValueSet(String keyPath, String valueName, String value) {
+ if (installationLogger != null) {
+ String details = valueName + " = " + (value != null && value.length() > 50 ? value.substring(0, 50) + "..." : value);
+ installationLogger.logRegistryOperation(InstallationLogger.RegistryOperation.VALUE_SET,
+ "HKCU\\" + keyPath, details);
+ }
+ }
+
public File getUninstallerPath() {
String suffix = "";
if (appInfo.getNpmVersion() != null && appInfo.getNpmVersion().startsWith("0.0.0-")) {
@@ -441,6 +486,7 @@ private void createKeyRecursive(String key) {
}
if (!registryOps.keyExists(key)) {
registryOps.createKey(key);
+ logRegistryKeyCreated(key);
}
}
@@ -802,26 +848,64 @@ private void unregisterDirectoryAssociations() {
}
public void unregister(File backupLogFile) throws IOException {
+ unregister(backupLogFile, null);
+ }
+
+ public void unregister(InstallationLogger logger) throws IOException {
+ unregister(null, logger);
+ }
+
+ public void unregister(File backupLogFile, InstallationLogger logger) throws IOException {
+ // Use passed logger or fall back to instance logger
+ InstallationLogger activeLogger = logger != null ? logger : this.installationLogger;
+
+ if (activeLogger != null) {
+ activeLogger.logInfo("Starting registry unregistration");
+ }
if (appInfo.hasDocumentTypes()) {
unregisterFileExtensions();
+ if (activeLogger != null) {
+ activeLogger.logInfo("Unregistered file extensions");
+ }
}
if (appInfo.hasUrlSchemes()) {
unregisterUrlSchemes();
+ if (activeLogger != null) {
+ activeLogger.logInfo("Unregistered URL schemes");
+ }
}
if (appInfo.hasDirectoryAssociation()) {
unregisterDirectoryAssociations();
+ if (activeLogger != null) {
+ activeLogger.logInfo("Unregistered directory associations");
+ }
}
deleteUninstallEntry();
+ if (activeLogger != null) {
+ activeLogger.logRegistryOperation(InstallationLogger.RegistryOperation.KEY_DELETED,
+ "HKCU\\" + getUninstallKey(), "Uninstaller entry");
+ }
+
deleteRegistryKey();
+ if (activeLogger != null) {
+ activeLogger.logRegistryOperation(InstallationLogger.RegistryOperation.KEY_DELETED,
+ "HKCU\\" + getRegistryPath(), "Application registry key");
+ }
if (!skipWinRegistryOperations && backupLogFile != null && backupLogFile.exists()) {
new WinRegistry().regImport(backupLogFile);
+ if (activeLogger != null) {
+ activeLogger.logInfo("Restored registry from backup: " + backupLogFile.getAbsolutePath());
+ }
}
+ if (activeLogger != null) {
+ activeLogger.logInfo("Registry unregistration complete");
+ }
}
public void deleteRegistryKey() {
@@ -884,11 +968,18 @@ private void deleteFileTypeEntry() {
}
public void register() throws IOException {
+ if (installationLogger != null) {
+ installationLogger.logInfo("Starting Windows registry operations");
+ }
+
WinRegistry registry = new WinRegistry();
String registryPath = getRegistryPath();
String capabilitiesPath = getCapabilitiesPath();
if (!skipWinRegistryOperations && registryOps.keyExists(registryPath)) {
registry.exportKey(registryPath, backupLog);
+ if (installationLogger != null) {
+ installationLogger.logInfo("Exported existing registry key for backup: " + registryPath);
+ }
}
// Ensure the base registry path exists for the application
@@ -896,9 +987,12 @@ public void register() throws IOException {
createKeyRecursive(capabilitiesPath);
registryOps.setStringValue(capabilitiesPath, "ApplicationName", appInfo.getTitle());
+ logRegistryValueSet(capabilitiesPath, "ApplicationName", appInfo.getTitle());
registryOps.setStringValue(capabilitiesPath, "ApplicationDescription", appInfo.getDescription());
+ logRegistryValueSet(capabilitiesPath, "ApplicationDescription", appInfo.getDescription());
if (icon != null && icon.exists()) {
registryOps.setStringValue(capabilitiesPath, "ApplicationIcon", icon.getAbsolutePath()+",0");
+ logRegistryValueSet(capabilitiesPath, "ApplicationIcon", icon.getAbsolutePath()+",0");
}
if (appInfo.hasDocumentTypes() || appInfo.hasUrlSchemes() || appInfo.hasDirectoryAssociation()) {
@@ -929,25 +1023,38 @@ public void register() throws IOException {
getRegisteredAppName(),
getCapabilitiesPath()
);
+ logRegistryValueSet(registeredAppsKey, getRegisteredAppName(), getCapabilitiesPath());
// Now to register the uninstaller
+ if (installationLogger != null) {
+ installationLogger.logInfo("Registering uninstaller in Add/Remove Programs");
+ }
createKeyRecursive(getUninstallKey());
if (icon != null && icon.exists()) {
registryOps.setStringValue(getUninstallKey(), "DisplayIcon", icon.getAbsolutePath() + ",0");
+ logRegistryValueSet(getUninstallKey(), "DisplayIcon", icon.getAbsolutePath() + ",0");
}
registryOps.setStringValue(getUninstallKey(), "DisplayName", appInfo.getTitle());
+ logRegistryValueSet(getUninstallKey(), "DisplayName", appInfo.getTitle());
registryOps.setStringValue(getUninstallKey(), "DisplayVersion", appInfo.getVersion());
+ logRegistryValueSet(getUninstallKey(), "DisplayVersion", appInfo.getVersion());
registryOps.setLongValue(getUninstallKey(), 1);
registryOps.setStringValue(getUninstallKey(), "Publisher", appInfo.getVendor());
- registryOps.setStringValue(
- getUninstallKey(),
- "UninstallString",
- "\"" + getUninstallerPath().getAbsolutePath() + "\" uninstall"
- );
+ logRegistryValueSet(getUninstallKey(), "Publisher", appInfo.getVendor());
+ String uninstallString = "\"" + getUninstallerPath().getAbsolutePath() + "\" uninstall";
+ registryOps.setStringValue(getUninstallKey(), "UninstallString", uninstallString);
+ logRegistryValueSet(getUninstallKey(), "UninstallString", uninstallString);
if (!skipWinRegistryOperations) {
registry.notifyFileAssociationsChanged();
+ if (installationLogger != null) {
+ installationLogger.logInfo("Notified Windows of file association changes");
+ }
+ }
+
+ if (installationLogger != null) {
+ installationLogger.logInfo("Windows registry operations complete");
}
diff --git a/installer/src/main/java/ca/weblite/jdeploy/installer/win/UninstallWindows.java b/installer/src/main/java/ca/weblite/jdeploy/installer/win/UninstallWindows.java
index 69bcfc1b..3960705b 100644
--- a/installer/src/main/java/ca/weblite/jdeploy/installer/win/UninstallWindows.java
+++ b/installer/src/main/java/ca/weblite/jdeploy/installer/win/UninstallWindows.java
@@ -1,6 +1,7 @@
package ca.weblite.jdeploy.installer.win;
import ca.weblite.jdeploy.installer.CliInstallerConstants;
+import ca.weblite.jdeploy.installer.logging.InstallationLogger;
import ca.weblite.jdeploy.installer.util.PackagePathResolver;
import ca.weblite.jdeploy.installer.cli.WindowsCliCommandInstaller;
import ca.weblite.tools.io.MD5;
@@ -23,6 +24,7 @@ public class UninstallWindows {
private String source;
private String appFileName;
+ private InstallationLogger installationLogger;
public UninstallWindows(
String packageName,
@@ -30,6 +32,17 @@ public UninstallWindows(
String version,
String appTitle,
InstallWindowsRegistry installer
+ ) {
+ this(packageName, source, version, appTitle, installer, null);
+ }
+
+ public UninstallWindows(
+ String packageName,
+ String source,
+ String version,
+ String appTitle,
+ InstallWindowsRegistry installer,
+ InstallationLogger logger
) {
this.packageName = packageName;
this.source = source;
@@ -38,6 +51,7 @@ public UninstallWindows(
this.installWindowsRegistry = installer;
this.fullyQualifiedPackageName = installer.getFullyQualifiedPackageName();
this.appFileName = this.appTitle;
+ this.installationLogger = logger;
if (version != null && version.startsWith("0.0.0-")) {
this.appFileName += " " + version.substring(version.indexOf("-")+1);
}
@@ -120,6 +134,9 @@ private Iterable getVersionDirectories() {
}
private void deletePackage() throws IOException {
+ if (installationLogger != null) {
+ installationLogger.logSection("Deleting Package Files");
+ }
// Delete from all possible locations (architecture-specific and legacy)
File[] allPossiblePaths = PackagePathResolver.getAllPossiblePackagePaths(packageName, version, source);
@@ -133,12 +150,20 @@ private void deletePackage() throws IOException {
!child.getName().startsWith("0.0.0-")) {
System.out.println("Deleting version dir: " + child.getAbsolutePath());
FileUtils.deleteDirectory(child);
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED,
+ child.getAbsolutePath(), "Version directory");
+ }
}
}
} else if (possiblePath.exists()) {
// Delete specific version directory
System.out.println("Deleting version dir: " + possiblePath.getAbsolutePath());
FileUtils.deleteDirectory(possiblePath);
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED,
+ possiblePath.getAbsolutePath(), "Version directory");
+ }
}
}
}
@@ -174,15 +199,26 @@ private void cleanupPackageDir() throws IOException {
if (numVersionDirectoriesRemaining == 0) {
System.out.println("Deleting package dir: " + packageDir.getAbsolutePath());
FileUtils.deleteDirectory(packageDir);
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED,
+ packageDir.getAbsolutePath(), "Empty package directory");
+ }
}
}
}
}
private void deleteApp() throws IOException {
+ if (installationLogger != null) {
+ installationLogger.logSection("Deleting Application Directory");
+ }
if (getAppDirPath().exists()) {
System.out.println("Deleting app dir: "+getAppDirPath().getAbsolutePath());
FileUtils.deleteDirectory(getAppDirPath());
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED,
+ getAppDirPath().getAbsolutePath(), "Application directory");
+ }
}
}
@@ -204,6 +240,10 @@ private void removeDesktopAlias() {
if (getDesktopLink(suffix).exists()) {
System.out.println("Deleting desktop link: "+getDesktopLink(suffix).getAbsolutePath());
getDesktopLink(suffix).delete();
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.DELETED,
+ getDesktopLink(suffix).getAbsolutePath(), null);
+ }
}
}
}
@@ -213,6 +253,10 @@ private void removeStartMenuLink() {
if (getStartMenuLink(suffix).exists()) {
System.out.println("Deleting start menu link: " + getStartMenuLink(suffix).getAbsolutePath());
getStartMenuLink(suffix).delete();
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.DELETED,
+ getStartMenuLink(suffix).getAbsolutePath(), null);
+ }
}
}
}
@@ -222,32 +266,63 @@ private void removeProgramsMenuLink() {
if (getProgramsMenuLink(suffix).exists()) {
System.out.println("Deleting programs menu link: " + getProgramsMenuLink(suffix).getAbsolutePath());
getProgramsMenuLink(suffix).delete();
+ if (installationLogger != null) {
+ installationLogger.logShortcut(InstallationLogger.FileOperation.DELETED,
+ getProgramsMenuLink(suffix).getAbsolutePath(), null);
+ }
}
}
}
public void uninstall() throws IOException {
+ if (installationLogger != null) {
+ installationLogger.logInfo("Starting uninstallation of " + appTitle + " version " + version);
+ installationLogger.logSection("Removing Shortcuts");
+ }
+
removeDesktopAlias();
removeProgramsMenuLink();
removeStartMenuLink();
deletePackage();
cleanupPackageDir();
deleteApp();
- installWindowsRegistry.unregister(null);
+
+ if (installationLogger != null) {
+ installationLogger.logSection("Removing Registry Entries");
+ }
+ installWindowsRegistry.unregister(installationLogger);
// Delegate CLI command cleanup to WindowsCliCommandInstaller
+ if (installationLogger != null) {
+ installationLogger.logSection("Removing CLI Commands");
+ }
File appDir = getAppDirPath();
try {
WindowsCliCommandInstaller cliInstaller = new WindowsCliCommandInstaller();
+ cliInstaller.setInstallationLogger(installationLogger);
cliInstaller.uninstallCommands(appDir);
} catch (Exception ex) {
System.err.println("Warning: Failed to uninstall CLI commands: " + ex.getMessage());
+ if (installationLogger != null) {
+ installationLogger.logError("Failed to uninstall CLI commands", ex);
+ }
}
+ if (installationLogger != null) {
+ installationLogger.logSection("Cleanup");
+ }
File uninstallerJDeployFiles = new File(getUninstallerPath().getParentFile(), ".jdeploy-files");
if (uninstallerJDeployFiles.exists()) {
FileUtils.deleteDirectory(uninstallerJDeployFiles);
+ if (installationLogger != null) {
+ installationLogger.logDirectoryOperation(InstallationLogger.DirectoryOperation.DELETED,
+ uninstallerJDeployFiles.getAbsolutePath(), "Uninstaller jDeploy files");
+ }
+ }
+
+ if (installationLogger != null) {
+ installationLogger.logInfo("Scheduling uninstaller self-deletion");
}
scheduleDeleteUninstaller();
diff --git a/installer/src/test/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstallerBinDirTest.java b/installer/src/test/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstallerBinDirTest.java
index ad2f830c..a2b7b8c2 100644
--- a/installer/src/test/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstallerBinDirTest.java
+++ b/installer/src/test/java/ca/weblite/jdeploy/installer/cli/AbstractUnixCliCommandInstallerBinDirTest.java
@@ -24,7 +24,7 @@ public void setUp() {
// Create a concrete implementation for testing
installer = new AbstractUnixCliCommandInstaller() {
@Override
- protected void writeCommandScript(File scriptPath, String launcherPath, String commandName, java.util.List args) throws java.io.IOException {
+ protected void writeCommandScript(File scriptPath, String launcherPath, ca.weblite.jdeploy.models.CommandSpec command) throws java.io.IOException {
// No-op for this test
}
diff --git a/installer/src/test/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstallerBinDirTest.java b/installer/src/test/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstallerBinDirTest.java
index cbb9e9f8..284861c7 100644
--- a/installer/src/test/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstallerBinDirTest.java
+++ b/installer/src/test/java/ca/weblite/jdeploy/installer/cli/MacCliCommandInstallerBinDirTest.java
@@ -191,7 +191,7 @@ public void testMacInstallerDelegatesCorrectly() {
// by comparing behavior with direct instantiation of abstract class
AbstractUnixCliCommandInstaller abstractInstaller = new AbstractUnixCliCommandInstaller() {
@Override
- protected void writeCommandScript(File scriptPath, String launcherPath, String commandName, java.util.List args) throws java.io.IOException {
+ protected void writeCommandScript(File scriptPath, String launcherPath, ca.weblite.jdeploy.models.CommandSpec command) throws java.io.IOException {
// No-op for this test
}
diff --git a/rfc/cli-commands-in-installer.md b/rfc/cli-commands-in-installer.md
index 6fffce1c..7c1ffb75 100644
--- a/rfc/cli-commands-in-installer.md
+++ b/rfc/cli-commands-in-installer.md
@@ -40,6 +40,7 @@ Add an optional `jdeploy.commands` object to `package.json` where command names
- `args` (array of strings, optional): extra static arguments to pass to the launcher when this command is invoked (commonly JVM properties like `-D...` or app-specific flags).
- `description` (string, optional): a short description of what the command does, displayed in the installer UI to help users understand the command's purpose.
+- `implements` (array of strings, optional): specifies special behaviors for this command. Each string must be one of: `"updater"`, `"service_controller"`, `"launcher"`. See "Command Implementations" section below for detailed behavior specifications.
Example:
```json
@@ -49,14 +50,23 @@ Example:
"args": [
"-Dmy.system.prop=foo",
"--my-app-arg=bar"
- ]
+ ],
+ "implements": ["updater"]
},
"myapp-admin": {
"args": [
"--mode=admin"
]
+ },
+ "myapp": {
+ "description": "Open files in MyApp",
+ "implements": ["launcher"]
+ },
+ "myappctl": {
+ "description": "Control MyApp services",
+ "implements": ["service_controller", "updater"]
}
- ]
+ }
}
```
@@ -67,6 +77,7 @@ Validation rules:
- This prevents attempts to place commands into arbitrary paths or overwrite paths by containing `../`.
- `args` if present MUST be an array of strings. Empty arrays are allowed.
- `description` if present MUST be a string. It is used to display context about the command in the installer UI. The description should be concise (recommended: under 80 characters) and provide a brief explanation of what the command does.
+- `implements` if present MUST be an array of strings. Each string MUST be one of: `"updater"`, `"service_controller"`, `"launcher"`. Empty arrays are allowed. Invalid values should be rejected at build-time.
- The `commands` object itself may be empty (`"commands": {}` is valid and results in no CLI command wrappers being installed).
- The installer should validate this schema at bundle/installer build-time and reject invalid configs (or warn + skip installing bad entries).
@@ -104,6 +115,60 @@ If the launcher receives `--jdeploy:command` it must stop any GUI bootstrap earl
NOTE: The launcher is a black box to this project. We include these specifications here to ensure the installer and packaging work correctly with the launcher behavior.
+## Command Implementations
+
+Commands may declare special behaviors via the optional `implements` array. Each implementation type modifies how the command wrapper script behaves:
+
+### Updater Implementation
+
+When a command includes `"updater"` in its `implements` array, the wrapper script checks for a single `update` argument:
+
+- **If called with exactly one argument `update`**: The wrapper calls the app's CLI binary with `--jdeploy:update` (omitting the usual `--jdeploy:command={commandName}`).
+ - Example: `myapp-cli update` → `launcher --jdeploy:update`
+- **If called with no arguments or with additional arguments**: The wrapper behaves like a normal command, passing `--jdeploy:command={commandName}`.
+ - Example: `myapp-cli` → `launcher --jdeploy:command=myapp-cli --`
+ - Example: `myapp-cli foo bar` → `launcher --jdeploy:command=myapp-cli -- foo bar`
+
+**Use case**: Allows users to trigger application updates from the command line using a dedicated CLI command.
+
+### Launcher Implementation
+
+When a command includes `"launcher"` in its `implements` array, the wrapper script launches the desktop application instead of invoking CLI mode:
+
+- **Behavior**: All arguments are passed through to the binary and are assumed to be URLs or file paths to open.
+- **Platform-specific handling**:
+ - **macOS**: Use the `open` command to launch the `.app` bundle with arguments.
+ - Example: `myapp file.txt` → `open -a MyApp.app file.txt`
+ - **Windows/Linux**: Call the app binary script directly with arguments.
+ - Example: `myapp file.txt` → `launcher file.txt`
+- **No `--jdeploy:command` flag**: The launcher implementation does NOT pass the `--jdeploy:command={commandName}` flag.
+
+**Use case**: Provides a convenient CLI shortcut to open files or URLs in the GUI application.
+
+### Service Controller Implementation
+
+When a command includes `"service_controller"` in its `implements` array, the wrapper script intercepts calls where the first argument is `service`:
+
+- **If the first argument is `service`**: The wrapper calls the app's CLI binary with `--jdeploy:command={commandName}` and `--jdeploy:service` followed by the remaining arguments.
+ - Example: `myappctl service start` → `launcher --jdeploy:command=myappctl --jdeploy:service start`
+ - Example: `myappctl service stop` → `launcher --jdeploy:command=myappctl --jdeploy:service stop`
+ - Example: `myappctl service status` → `launcher --jdeploy:command=myappctl --jdeploy:service status`
+- **If the first argument is NOT `service`**: The wrapper processes the call like a normal command.
+ - Example: `myappctl version` → `launcher --jdeploy:command=myappctl -- version`
+
+**Use case**: Provides a standardized interface for controlling background services or daemons managed by the application.
+
+### Multiple Implementations
+
+A command may include multiple implementation types in its `implements` array. The wrapper script MUST check for special behaviors in the following order:
+
+1. Check for `launcher` implementation (if present, launch desktop app and exit)
+2. Check for `updater` implementation and single `update` argument (if match, call with `--jdeploy:update` and exit)
+3. Check for `service_controller` implementation and first argument `service` (if match, call with `--jdeploy:service` and exit)
+4. If no special behavior matches, invoke as a normal command with `--jdeploy:command={commandName}`
+
+**Note**: In practice, `launcher` is mutually exclusive with other implementations since it does not use `--jdeploy:command`. Combining `updater` and `service_controller` is valid and allows a single command to handle both updates and service control.
+
## Platform behaviors
High-level goal: CLI command scripts/wrappers are installed per-user, in a per-app, architecture-specific directory that is added to the user's PATH.
@@ -277,7 +342,9 @@ Fields:
## Example artifacts
-Linux shell script (example):
+### Standard Command (no special implementations)
+
+Linux shell script:
```sh
#!/usr/bin/env sh
# Installed at: ~/.jdeploy/bin-x64/myapp/myapp-cli
@@ -287,7 +354,7 @@ exec "/home/alice/.jdeploy/apps/myapp/my-app" \
"$@"
```
-macOS script (example):
+macOS script:
```sh
#!/usr/bin/env sh
# Installed at: ~/.jdeploy/bin-arm64/myapp/myapp-cli
@@ -297,7 +364,7 @@ exec "/Users/alice/Applications/MyApp.app/Contents/MacOS/Client4JLauncher-cli" \
"$@"
```
-Windows `.cmd` wrapper (example):
+Windows `.cmd` wrapper:
```cmd
@echo off
REM Installed at: %USERPROFILE%\.jdeploy\bin-x64\myapp\myapp-cli.cmd
@@ -305,6 +372,101 @@ set "LAUNCHER=%USERPROFILE%\.jdeploy\apps\MyApp\MyApp-cli.exe"
"%LAUNCHER%" --jdeploy:command=myapp-cli -- %*
```
+### Command with Updater Implementation
+
+Linux/macOS script with `"implements": ["updater"]`:
+```sh
+#!/usr/bin/env sh
+# Check if single argument is "update"
+if [ "$#" -eq 1 ] && [ "$1" = "update" ]; then
+ exec "/home/alice/.jdeploy/apps/myapp/my-app" --jdeploy:update
+else
+ exec "/home/alice/.jdeploy/apps/myapp/my-app" --jdeploy:command=myapp-cli -- "$@"
+fi
+```
+
+Windows `.cmd` wrapper with `"implements": ["updater"]`:
+```cmd
+@echo off
+REM Check if single argument is "update"
+if "%~1"=="update" if "%~2"=="" (
+ "%LAUNCHER%" --jdeploy:update
+) else (
+ "%LAUNCHER%" --jdeploy:command=myapp-cli -- %*
+)
+```
+
+### Command with Launcher Implementation
+
+macOS script with `"implements": ["launcher"]`:
+```sh
+#!/usr/bin/env sh
+# Launch the desktop app using the open command
+exec open -a "/Users/alice/Applications/MyApp.app" "$@"
+```
+
+Linux script with `"implements": ["launcher"]`:
+```sh
+#!/usr/bin/env sh
+# Launch the desktop app directly
+exec "/home/alice/.jdeploy/apps/myapp/my-app" "$@"
+```
+
+Windows `.cmd` wrapper with `"implements": ["launcher"]`:
+```cmd
+@echo off
+set "LAUNCHER=%USERPROFILE%\.jdeploy\apps\MyApp\MyApp.exe"
+"%LAUNCHER%" %*
+```
+
+### Command with Service Controller Implementation
+
+Linux/macOS script with `"implements": ["service_controller"]`:
+```sh
+#!/usr/bin/env sh
+# Check if first argument is "service"
+if [ "$1" = "service" ]; then
+ shift
+ exec "/home/alice/.jdeploy/apps/myapp/my-app" --jdeploy:command=myappctl --jdeploy:service "$@"
+else
+ exec "/home/alice/.jdeploy/apps/myapp/my-app" --jdeploy:command=myappctl -- "$@"
+fi
+```
+
+Windows `.cmd` wrapper with `"implements": ["service_controller"]`:
+```cmd
+@echo off
+set "LAUNCHER=%USERPROFILE%\.jdeploy\apps\MyApp\MyApp-cli.exe"
+if "%~1"=="service" (
+ shift
+ "%LAUNCHER%" --jdeploy:command=myappctl --jdeploy:service %*
+) else (
+ "%LAUNCHER%" --jdeploy:command=myappctl -- %*
+)
+```
+
+### Command with Multiple Implementations
+
+Linux/macOS script with `"implements": ["service_controller", "updater"]`:
+```sh
+#!/usr/bin/env sh
+LAUNCHER="/home/alice/.jdeploy/apps/myapp/my-app"
+
+# Check for updater: single "update" argument
+if [ "$#" -eq 1 ] && [ "$1" = "update" ]; then
+ exec "$LAUNCHER" --jdeploy:update
+fi
+
+# Check for service_controller: first argument is "service"
+if [ "$1" = "service" ]; then
+ shift
+ exec "$LAUNCHER" --jdeploy:command=myappctl --jdeploy:service "$@"
+fi
+
+# Default: normal command
+exec "$LAUNCHER" --jdeploy:command=myappctl -- "$@"
+```
+
## Auto-Update Behavior
CLI command scripts reference the launcher by absolute path. The auto-update mechanism works as follows:
diff --git a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java
index 0748e5b5..3d992d90 100644
--- a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java
+++ b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpec.java
@@ -12,8 +12,9 @@ public class CommandSpec {
private final String name;
private final String description;
private final List args;
+ private final List implementations;
- public CommandSpec(String name, String description, List args) {
+ public CommandSpec(String name, String description, List args, List implementations) {
if (name == null) {
throw new IllegalArgumentException("Command name cannot be null");
}
@@ -24,6 +25,18 @@ public CommandSpec(String name, String description, List args) {
} else {
this.args = Collections.unmodifiableList(new ArrayList<>(args));
}
+ if (implementations == null) {
+ this.implementations = Collections.emptyList();
+ } else {
+ this.implementations = Collections.unmodifiableList(new ArrayList<>(implementations));
+ }
+ }
+
+ /**
+ * Constructor for backward compatibility (no implementations specified).
+ */
+ public CommandSpec(String name, String description, List args) {
+ this(name, description, args, null);
}
public String getName() {
@@ -38,18 +51,34 @@ public List getArgs() {
return args;
}
+ public List getImplementations() {
+ return implementations;
+ }
+
+ /**
+ * Checks if this command implements a specific behavior.
+ * @param implementation one of: "updater", "service_controller", "launcher"
+ * @return true if this command implements the given behavior
+ */
+ public boolean implements_(String implementation) {
+ return implementations.contains(implementation);
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CommandSpec that = (CommandSpec) o;
- return Objects.equals(name, that.name) && Objects.equals(description, that.description) && Objects.equals(args, that.args);
+ return Objects.equals(name, that.name) &&
+ Objects.equals(description, that.description) &&
+ Objects.equals(args, that.args) &&
+ Objects.equals(implementations, that.implementations);
}
@Override
public int hashCode() {
- return Objects.hash(name, description, args);
+ return Objects.hash(name, description, args, implementations);
}
@Override
@@ -58,6 +87,7 @@ public String toString() {
"name='" + name + '\'' +
", description='" + description + '\'' +
", args=" + args +
+ ", implementations=" + implementations +
'}';
}
}
diff --git a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java
index ba386e1a..4b27258d 100644
--- a/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java
+++ b/shared/src/main/java/ca/weblite/jdeploy/models/CommandSpecParser.java
@@ -27,6 +27,23 @@ public class CommandSpecParser {
*/
private static final String DANGEROUS_ARG_CHARS_REGEX = "[;|&`]|\\$\\(";
+ /**
+ * Valid implementation types for the "implements" property.
+ */
+ public static final String IMPL_UPDATER = "updater";
+ public static final String IMPL_SERVICE_CONTROLLER = "service_controller";
+ public static final String IMPL_LAUNCHER = "launcher";
+
+ private static final List VALID_IMPLEMENTATIONS;
+
+ static {
+ List impls = new ArrayList<>();
+ impls.add(IMPL_UPDATER);
+ impls.add(IMPL_SERVICE_CONTROLLER);
+ impls.add(IMPL_LAUNCHER);
+ VALID_IMPLEMENTATIONS = Collections.unmodifiableList(impls);
+ }
+
/**
* Parses and returns the list of CommandSpec objects declared in jdeploy config under commands.
* The returned list is sorted by command name to ensure deterministic ordering.
@@ -95,7 +112,26 @@ public static List parseCommands(JSONObject jdeployConfig) {
args.add(argValue);
}
}
- result.add(new CommandSpec(name, description, args));
+
+ List implementations = new ArrayList<>();
+ if (specObj.has("implements")) {
+ Object implObj = specObj.get("implements");
+ if (!(implObj instanceof JSONArray)) {
+ throw new IllegalArgumentException("Command '" + name + "': 'implements' must be an array of strings");
+ }
+ JSONArray implArr = (JSONArray) implObj;
+ for (int i = 0; i < implArr.length(); i++) {
+ Object el = implArr.get(i);
+ if (!(el instanceof String)) {
+ throw new IllegalArgumentException("Command '" + name + "': all 'implements' elements must be strings");
+ }
+ String implValue = (String) el;
+ validateImplementation(name, implValue, i);
+ implementations.add(implValue);
+ }
+ }
+
+ result.add(new CommandSpec(name, description, args, implementations));
}
// sort by name for deterministic order
@@ -123,6 +159,26 @@ private static void validateArg(String commandName, String arg, int index) {
}
}
+ /**
+ * Validates that an implementation string is one of the allowed values.
+ *
+ * @param commandName the command name (for error messages)
+ * @param implementation the implementation string to validate
+ * @param index the index of the implementation in the array (for error messages)
+ * @throws IllegalArgumentException if the implementation is not a valid value or is null
+ */
+ private static void validateImplementation(String commandName, String implementation, int index) {
+ if (implementation == null) {
+ throw new IllegalArgumentException("Command '" + commandName + "': implementation at index " + index + " must not be null");
+ }
+ if (!VALID_IMPLEMENTATIONS.contains(implementation)) {
+ throw new IllegalArgumentException(
+ "Command '" + commandName + "': implementation at index " + index + " is invalid: '" + implementation + "'. " +
+ "Must be one of: " + String.join(", ", VALID_IMPLEMENTATIONS)
+ );
+ }
+ }
+
private CommandSpecParser() {
// Utility class, no instantiation
}