Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8762376
#989: add expression function framework for template variables
Paras14 Aug 6, 2026
a41eace
#989: resolve expressions during variable resolution
Paras14 Aug 6, 2026
d1945db
#989: do not persist template variables that could not be asked in ba…
Paras14 Aug 6, 2026
b315917
Merge branch 'main' of https://github.com/devonfw/ideasy into feature…
Paras14 Aug 7, 2026
c01ce8c
#989: Fix formatting issues
Paras14 Aug 7, 2026
53b84ea
Update CHANGELOG for version 2026.08.002
Paras14 Aug 12, 2026
39cbef5
Merge branch 'main' into feature/989-expression-functions
Paras14 Aug 12, 2026
0af5ed9
Merge remote-tracking branch 'upstream/main' into feature/989-express…
maybeec Aug 16, 2026
3aa99ce
#989: Add config location and default value arguments to @ask functions
Paras14 Aug 24, 2026
cb7b7cf
#989: Use WindowsPathSyntax for @path with native mode
Paras14 Aug 24, 2026
3e4dedc
#989: Report template errors as CliException instead of internal error
Paras14 Aug 24, 2026
3b2dd8c
#989: Mask secret variable values in log output
Paras14 Aug 25, 2026
ca3d70b
#989: Do not print a stacktrace for expected template errors
Paras14 Aug 25, 2026
7a74b11
#989: Merge upstream/main into 989-expression-functions
Paras14 Aug 25, 2026
07af2e2
#989: address review findings on documentation, duplication and ordering
Paras14 Aug 26, 2026
6f4068b
Merge branch 'main' into feature/989-expression-functions
Paras14 Aug 26, 2026
87828e4
#989: Rename test values that look like real API keys
Paras14 Aug 26, 2026
32cddc4
Merge branch 'main' into feature/989-expression-functions
Paras14 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Release with new features and bugfixes:
* https://github.com/devonfw/IDEasy/issues/2251[#2251]: Provide generic uninstall support for globally installed tools (windows)
* https://github.com/devonfw/IDEasy/issues/1135[#1135]: Fix PowerShell env variable initialization on Windows by sourcing functions from the PowerShell profile
* https://github.com/devonfw/IDEasy/issues/741[#741]: Add a warning message for legacy devonfw-ide settings users
* https://github.com/devonfw/IDEasy/issues/989[#989]: Allow expressions in template variable definitions
Comment thread
Paras14 marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, that it took too long but we need to move to the next release.

* https://github.com/devonfw/IDEasy/issues/1933[#1933]: Added a console panel to the GUI

The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/49?closed=1[milestone 2026.08.002].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.function.Predicate;
import java.util.logging.FileHandler;
import java.util.logging.LogManager;
Expand Down Expand Up @@ -178,8 +180,18 @@ public abstract class AbstractIdeContext implements IdeContext, IdeLogArgFormatt

private WindowsHelper windowsHelper;

/** The replacement used to mask a secret in log output. */
private static final String SECRET_MASK = "********";

/** Minimum length of a value to be masked as a secret in log output. Masking a very short value would corrupt unrelated log messages. */
private static final int SECRET_MIN_LENGTH = 3;

private final Map<String, String> privacyMap;

private final Set<String> secrets;

private final Set<String> secretVariables;

private Path bash;

private boolean julConfigured;
Expand All @@ -200,6 +212,8 @@ public AbstractIdeContext(IdeStartContextImpl startContext, Path workingDirector
this.startContext = startContext;
this.startContext.setArgFormatter(this);
this.privacyMap = new HashMap<>();
this.secrets = new HashSet<>();
this.secretVariables = new HashSet<>();
this.systemInfo = SystemInfoImpl.INSTANCE;
if (isTest()) {
configureJavaUtilLogging(null);
Expand Down Expand Up @@ -1050,9 +1064,41 @@ public String formatArgument(Object argument) {
}
result = PrivacyUtil.removeSensitivePathInformation(result);
}
// Secrets are masked independent of the privacy mode: a value the user entered as a secret or that belongs to a
// variable marked as secret must never appear in any log output. This is done here since formatArgument is the
// single place all log arguments pass through, so no individual log statement can be forgotten.
for (String secret : this.secrets) {
result = result.replace(secret, SECRET_MASK);
}
return result;
}

@Override
public void addSecretVariable(String name) {

if ((name != null) && !name.isEmpty()) {
this.secretVariables.add(name);
}
}

@Override
public void addSecretValue(String name, String value) {

if (this.secretVariables.contains(name)) {
addSecret(value);
}
}

/**
* @param secret the secret value to mask in all log output. Ignored if {@code null} or shorter than {@link #SECRET_MIN_LENGTH}.
*/
protected void addSecret(String secret) {

if ((secret != null) && (secret.length() >= SECRET_MIN_LENGTH)) {
this.secrets.add(secret);
}
}

/**
* @param path the sensitive {@link Path} to
* @param replacement the replacement to mask the {@link Path} in log output.
Expand Down Expand Up @@ -1082,6 +1128,27 @@ private void resetPrivacyMap() {
@Override
public String askForInput(String message, String defaultValue) {

return ask(message, defaultValue, false);
}

@Override
public String askForSecret(String message, String defaultValue) {
Comment thread
Paras14 marked this conversation as resolved.

return ask(message, defaultValue, true);
}

/**
* Asks the user for a value, re-asking while the input is empty and a default value is given.
*
* @param message the question to ask.
* @param defaultValue the value to return if the user accepts the default (by entering an empty value) or {@code null} to re-ask until a value is
* entered.
* @param secret - {@code true} to read the input in a masked way (see {@link #readSecretLine()}) and to mask it in the log output, {@code false} to
* read it as plain text.
* @return the entered value or the default value.
*/
private String ask(String message, String defaultValue, boolean secret) {

while (true) {
if (!message.isBlank()) {
IdeLogLevel.INTERACTION.log(LOG, message);
Expand All @@ -1093,11 +1160,18 @@ public String askForInput(String message, String defaultValue) {
throw new CliAbortException();
}
}
String input = readLine().trim();
// for a secret the input is not trimmed so that a leading or trailing whitespace that is part of the password or a pasted token is preserved
String input = secret ? readSecretLine() : readLine().trim();
if (!input.isEmpty()) {
if (secret) {
addSecret(input);
}
return input;
} else {
if (defaultValue != null) {
if (secret) {
addSecret(defaultValue);
}
return defaultValue;
}
}
Expand Down Expand Up @@ -1171,6 +1245,15 @@ private static String computeOptionKey(String option) {
*/
protected abstract String readLine();

/**
* @return the secret input from the end-user (e.g. read from the console without echoing it). The default implementation simply delegates to
* {@link #readLine()} so that sub-classes without a secure console (e.g. in tests) work out of the box.
*/
protected String readSecretLine() {

return readLine();
}

private static <O> void addMapping(Map<String, O> mapping, String key, O option) {

O duplicate = mapping.put(key, option);
Expand Down
36 changes: 36 additions & 0 deletions cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,42 @@ default String askForInput(String message) {
return askForInput(message, null);
}

/**
* Asks the user for a single secret input (e.g. a password or API token). Unlike {@link #askForInput(String, String)} the input is not echoed to the console
* if a secure console is available.
*
* @param message The information message to display.
* @param defaultValue The default value to return when no input is provided or {@code null} to keep asking until the user entered a non empty value.
* @return The secret input from the user, or the default value if no input is provided.
*/
String askForSecret(String message, String defaultValue);

/**
* Asks the user for a single secret input (e.g. a password or API token).
*
* @param message The information message to display.
* @return The secret input from the user.
*/
default String askForSecret(String message) {
return askForSecret(message, null);
}

/**
* Marks the variable with the given name as secret so that its value is masked in all log output, even if the value is not entered by the user but read from
* an existing {@code ide.properties}.
*
* @param name the name of the variable (e.g. "MY_API_TOKEN").
*/
void addSecretVariable(String name);

/**
* Registers the value of a variable as secret if the variable was marked via {@link #addSecretVariable(String)}. Has to be called before the value is logged.
*
* @param name the name of the variable.
* @param value the value of the variable.
*/
void addSecretValue(String name, String value);
Comment on lines +303 to +317

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great that you care about security of credentials. I appreciate that very much.
However, I see some problems here:

  1. If the function was not invoked this method gets never called and hence we have no clue what variables are secrets.
  2. If we really want to protected secrets, we IMHO need to store them encrypted (see how maven is doing that or spring-boot with jasypt spring-boot starter is solving it). This is a complex thing to implement and should deserve its own story if we ever need to support it. The other alternative would be not to persist secrets what would be quite simple. This works fine for templates that are instantiated only once (e.g. in settings/templates or in workspace/setup). For the moment, I would go for KISS and think about this way.
  3. Sometimes it is easier and more pragmatic to decide for a convention than trying to implement a bullet proof solution: We can use the convention that secret variables have to follow specific naming rules e.g. start or end with one of SECRET, PASSWORD, or API_KEY. Then we can avoid this complex runtime state of secret variable names registered in IdeContext. Also you could even enforce that the function ask-secret only accepts variables following this naming pattern. Then we could also detect secret values already when we create our environment and reading the variables from the convention.


/**
* @param question the question to ask.
* @param args arguments for filling the templates
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ protected String readLine() {
}
}

@Override
protected String readSecretLine() {

if (this.scanner == null) {
char[] password = System.console().readPassword();
if (password == null) {
return "";
}
return new String(password);
} else {
LOG.warn("System console not available - secret input will be visible while typing.");
Comment thread
Paras14 marked this conversation as resolved.
return this.scanner.nextLine();
}
}

@Override
public IdeProgressBar newProgressBar(String title, long size, String unitName, long unitSize) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
import org.slf4j.event.Level;

import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.expression.ExpressionContext;
import com.devonfw.tools.ide.expression.ExpressionFunctionManager;
import com.devonfw.tools.ide.expression.ExpressionParser;
import com.devonfw.tools.ide.variable.IdeVariables;
import com.devonfw.tools.ide.variable.VariableDefinition;
import com.devonfw.tools.ide.variable.VariableSyntax;
Expand All @@ -34,6 +37,8 @@ public abstract class AbstractEnvironmentVariables implements EnvironmentVariabl

private static final int MAX_RECURSION = 9;

private static final ExpressionParser EXPRESSION_PARSER = new ExpressionParser(ExpressionFunctionManager.get());

/**
* @see #getParent()
*/
Expand Down Expand Up @@ -206,14 +211,19 @@ private String resolveRecursive(String value, Object source, int recursion, Abst
}
recursion++;

// Expressions are evaluated before plain variables so that a function argument may contain variables and so that a variable value can never change
// the structure of an enclosing expression (e.g. by containing a quote or a parenthesis). Note that the variable pass below still scans the result
// of a function, exactly like it scans the value of a plain variable, so a resolved value containing "$[" is resolved further.
String withExpressions = EXPRESSION_PARSER.resolve(value, new EnvironmentExpressionContext(source, recursion, resolvedVars, context));

String resolved;
if (context.syntax == null) {
resolved = resolveWithSyntax(value, source, recursion, resolvedVars, context, VariableSyntax.SQUARE);
resolved = resolveWithSyntax(withExpressions, source, recursion, resolvedVars, context, VariableSyntax.SQUARE);
if (context.legacySupport) {
resolved = resolveWithSyntax(resolved, source, recursion, resolvedVars, context, VariableSyntax.CURLY);
}
} else {
resolved = resolveWithSyntax(value, source, recursion, resolvedVars, context, context.syntax);
resolved = resolveWithSyntax(withExpressions, source, recursion, resolvedVars, context, context.syntax);
}
return resolved;
}
Expand Down Expand Up @@ -357,6 +367,60 @@ public String toString() {
return getSource().toString();
}

/**
* Implementation of {@link ExpressionContext} that connects an {@link com.devonfw.tools.ide.expression.ExpressionFunction} with this
* {@link EnvironmentVariables} hierarchy.
*/
private final class EnvironmentExpressionContext implements ExpressionContext {

private final Object src;

private final int recursion;

private final AbstractEnvironmentVariables resolvedVars;

private final ResolveContext context;

private EnvironmentExpressionContext(Object src, int recursion, AbstractEnvironmentVariables resolvedVars, ResolveContext context) {

super();
this.src = src;
this.recursion = recursion;
this.resolvedVars = resolvedVars;
this.context = context;
}

@Override
public IdeContext getIdeContext() {

return AbstractEnvironmentVariables.this.context;
}

@Override
public String resolve(String value) {

return this.resolvedVars.resolveRecursive(value, this.src, this.recursion, this.resolvedVars, this.context);
Comment thread
hohwille marked this conversation as resolved.
}

@Override
public String getVariable(String name) {

return this.resolvedVars.getValue(name, false);
}

@Override
public void setVariable(String name, String value, EnvironmentVariablesType type) {

EnvironmentVariables variables = getByType(type);
if (variables instanceof EnvironmentVariablesPropertiesFile propertiesFile) {
propertiesFile.set(name, value);
propertiesFile.save();
Comment thread
Paras14 marked this conversation as resolved.
} else {
LOG.warn("Cannot persist variable {} since no configuration file is available for {}.", name, type);
}
}
}

/**
* Simple record for the immutable arguments of recursive resolve methods.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public String getFlat(String name) {
if (value == null) {
LOG.trace("{}: Variable {} is undefined.", getSource(), name);
} else {
// register the value before it is logged so that a secret variable read from an existing ide.properties is
// masked as well, not only a value that was just entered by the user
this.context.addSecretValue(name, value);
LOG.trace("{}: Variable {}={}", getSource(), name, value);
WindowsPathSyntax pathSyntax = this.context.getPathSyntax();
if (pathSyntax != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.devonfw.tools.ide.expression;

import com.devonfw.tools.ide.context.IdeContext;
import com.devonfw.tools.ide.environment.EnvironmentVariablesType;

/**
* Interface for the context available to an {@link ExpressionFunction} while an expression is evaluated.
*/
public interface ExpressionContext {

/**
* @return the {@link IdeContext}.
*/
IdeContext getIdeContext();

/**
* Resolves variables in the given value. Used to resolve arguments of a function that may themselves contain
* variables or nested expressions (e.g. {@code @path('$[IDE_HOME]/software/node')}).
*
* @param value the value to resolve.
* @return the given value with variables and nested expressions resolved.
*/
String resolve(String value);

/**
* @param name the name of the variable.
* @return the value of the variable or {@code null} if not defined in any level of the hierarchy.
*/
String getVariable(String name);

/**
* Persists the given variable to the {@code ide.properties} of the given {@link EnvironmentVariablesType configuration location} so the user is not asked
* again.
*
* @param name the name of the variable.
* @param value the value to persist.
* @param type the {@link EnvironmentVariablesType} determining the {@code ide.properties} to write to.
*/
void setVariable(String name, String value, EnvironmentVariablesType type);

}
Loading