Creating Script Wrappers
Consider that you have a plugin that already has node
or terraform
and you want to try something on the command-line with the tool directly.
You do not want to install the tool again if you could possibly just use the already cached version.
It would be of the correct version as required by the project in any case.
You are probably very familiar with the Gradle wrapper.
It would be nice under certain circumstances, to create wrappers that will call the executables from distributions that were installed using the DistributionInstaller
.
Since 0.14, Grolifant has offered two abstract task types to help you add such functionality to your plugins.
These task types attempt to address the following:
-
Create wrappers for tools be it executables or scripts that will point to the correct version as required by the specific project.
-
Realise it is out of date if the version or location of the distribution/tool changes.
-
Cache the distribution/tool if it is not yet cached.
There are two tasks involved:
-
AbstractWrapperGeneratorTask - Extend this as the task that will create the wrappers.
-
AbstractWrapperCacheBinaryTask - Extend this as the task that will cache the binary, if it has not already been cached.
Example creating the wrapper tasks
In this example you would like to create a plugin for Hashicorp’s Packer.
-
you have already created an extension class which extends AbstractToolExtension and is called
PackerExtension
. -
this class knows how to download
packer
for the appropriate platform which you probably implemented using AbstractDistributionInstaller.
Since 0.14 the only supported implementation is to place the template files in a directory path in resources and then substitute values by tokens. This implementation uses Ant ReplaceTokens under the hood.
Creating a caching task
Let’s start by creating the caching task first as it is simpler to implement. Create a task type that extends AbstractWrapperCacheBinaryTask.
@CompileStatic
class MyPackerWrapperCacheTask extends AbstractWrapperCacheBinaryTask {
MyPackerWrapperCacheTask() {
super('packer-wrapper.properties') (1)
final packerExtension = project.extensions.getByType(MyToolExtensionWithDownloader) (2)
this.binaryLocation = packerExtension.executable.map { it.absolutePath }
this.binaryVersion = packerExtension.resolvedExecutableVersion()
}
private final Provider<String> binaryLocation
private final Provider<String> binaryVersion
}
1 | Set the name of a properties file that will store appropriate information about the cached binary that will be local to the project on a user’s computer or in CI. |
2 | As an example, we use the MyToolExtensionDownloader , that we defined in this example for creating tool wrappers.
There might other (better) ways to do this, but we keep to naivety in this example for simplicity of illustration. |
There are three minimum characteristics that need to be defined:
-
Version of the binary/script/distribution if it is set via
executableByVersion('1.2.3')
-
The location of the binary/script.
-
Description of the wrapper.
This is done by implementing three abstract methods.
@Override
protected Provider<String> getBinaryLocationProvider() {
this.binaryLocation (1)
}
@Override
protected Provider<String> getBinaryVersionProvider() {
this.binaryVersion (2)
}
@Override
protected String getPropertiesDescription() {
"Describes the Packer usage for the ${projectTools().projectNameProvider.get()} project" (3)
}
1 | Provide a path to the where the executable is located. In this case we do not need the file itself, but only the string path, as it will be written to the wrapper script. |
2 | Get the version of the downloadable executable. |
3 | A description that will be used in the properties file. |
If you execute an instance of your new task type it will automatically cache the binary/distribution dependent on how it has been defined. It will also generate a properties file into the project’s cache directory. This latter file should be ignored by source control. As the project cache directory should never be in source control, there is no additional step to be taken.
Creating the wrapper generator task
Start by extending AbstractWrapperGeneratorTask.
class MyPackerWrapper extends AbstractWrapperGeneratorTask {
MyPackerWrapper() {
MyPackerWrapper() {
super()
useWrapperTemplatesInResources( (1)
'/packer-wrappers', (2)
['wrapper-template.sh' : 'packerw', (3)
'wrapper-template.bat': 'packerw.bat'
]
)
this.locationPropertiesFile = project.objects.property(File) (4)
this.cacheTaskName = project.objects.property(String) (5)
}
private final Property<File> locationPropertiesFile
private final Property<String> cacheTaskName
}
}
1 | Define the wrappers to be generated. Although this is currently the only supported method, it has to be explicitly specified that wrapper templates are in resources. |
2 | Specify the resource path where to find the resource wrappers. This resource path will be scanned for files as defined below. |
3 | Specify a map which maps the names of files in the resource path to final file names.
The format is [ <WRAPPER TEMPLATE NAME> : <FINAL SCRIPT NAME> ] .
Although the final script names can be specified using a relative path, convention is to just place the file wrapper scripts in the project directory.
See example script wrappers for some inspiration. |
4 | Define a property to the location of the properties file that is generated by the cache task that you just wrote. |
5 | Define a property that will hold the name of the cache task. |
The next step is to provide tokens can be substituted by implementing the appropriate abstract methods.
[
APP_BASE_NAME : 'packer', (1)
APP_LOCATION_CONFIG : '/path/to/packer', (2)
CACHE_TASK_NAME : 'myCacheTask', (3)
GRADLE_WRAPPER_RELATIVE_PATH: '.', (4)
DOT_GRADLE_RELATIVE_PATH : './.gradle' (5)
]
1 | The name of the tool you are wrapping. |
2 | The name of the properties file. |
3 | The name of the task that caches the binary you are wrapping. |
4 | The relative path from this task’s project to the Gradle wrapper |
5 | The relative path from this task’s project to the project cache directory. |
At this point you can test the task, and it should generate wrappers. However, there are a number of shortcomings:
-
When somebody clones a project that contains the wrappers for the first time, there is a good chance that none of the wrapped binaries would be cached too and when they are cached they might end up at a different location due to the environment of the user.
-
The classic place to cache something is in the project cache directory, but this can be overridden from the command-line, so special care has to be taken.
-
You might have pulled an updated version of the project and the version of the wrapped binary has been changed by the project maintainers.
Return to the wrapper task and modify the constructor to also contain:
MyPackerWrapper() {
super()
useWrapperTemplatesInResources( (1)
'/packer-wrappers', (2)
['wrapper-template.sh' : 'packerw', (3)
'wrapper-template.bat': 'packerw.bat'
]
)
this.locationPropertiesFile = project.objects.property(File) (4)
this.cacheTaskName = project.objects.property(String) (5)
final root = fsOperations().projectRootDir
this.tokenValues = locationPropertiesFile.zip(cacheTaskName) { lpf, cacheTaskName -> (6)
[
APP_BASE_NAME : 'packer',
GRADLE_WRAPPER_RELATIVE_PATH: fsOperations().relativePathNotEmpty(root), (7)
DOT_GRADLE_RELATIVE_PATH : fsOperations().relativePath(lpf.parentFile), (8)
APP_LOCATION_CONFIG : lpf.name, (9)
CACHE_TASK_NAME : cacheTaskName (10)
] as Map<String, String>
}
}
private final Provider<Map<String, String>> tokenValues
1 | AbstractWrapperGeneratorTask extends GrolifantDefaultTask which means that you have direct access to fsOperations .
You can of course obtain the root directory via a different means. |
2 | Tie the location and the cache task name together in order to provide useful values to populate the tokens. |
3 | If the project uses a Gradle wrapper it is important that the tool wrapper script also use the Gradle wrapper to invoke the caching task. |
4 | Get the location of the project cache directory. |
5 | The name of the wrapper properties file. |
6 | The name of the cache task to invoke if either the wrapper properties file does not exist or the distribution/binary has not been cached. |
Now change your getTokenValuesAsMap
method.
@Override
protected Map<String, String> getTokenValuesAsMap() {
tokenValues.get()
}
Add ability to link tasks
In order to add the two tasks together, one more method is required.
void associateCacheTask(TaskProvider<MyPackerWrapperCacheTask> ct) {
inputs.files(ct) (1)
this.locationPropertiesFile.set(ct.flatMap { it.locationPropertiesFile }) (2)
this.cacheTaskName.set(ct.map { it.name }) (3)
}
1 | Link the tasks to file inputs.
This creates a dependency without using an explicit dependsOn . |
2 | Get the location of the properties file from the cache task. |
3 | Get the name of the cache task. |
You are now left to link the two tasks together, which you’ll do in a plugin
Putting everything in a plugin
It is recommended that the tasks created by convention are placed in a separate plugin and that the plugin users are recommended to only load this plugin in the root project of a multi-project.
In your plugin add the following code to the apply method
.
final cache = project.tasks.register('packerWrapperCache', MyPackerWrapperCacheTask)
project.tasks.register('packerWrapper', MyPackerWrapper) {
it.associateCacheTask(cache)
}
Example script wrappers
These are provided as starter points for wrapping simple binary tools. They have been hashed together from various other examples in open-source.
#!/usr/bin/env sh
#
# ============================================================================
# (C) Copyright Schalk W. Cronje 2016 - 2025
#
# This software is licensed under the Apache License 2.0
# See http://www.apache.org/licenses/LICENSE-2.0 for license details
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
# ============================================================================
#
##############################################################################
##
## ~~APP_BASE_NAME~~ wrapper up script for UN*X
##
##############################################################################
# Relative path from this script to the directory where the Gradle wrapper
# might be found.
GRADLE_WRAPPER_RELATIVE_PATH=~~GRADLE_WRAPPER_RELATIVE_PATH~~
# Relative path from this script to the project cache dir (usually .gradle).
DOT_GRADLE_RELATIVE_PATH=~~DOT_GRADLE_RELATIVE_PATH~~
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
APP_LOCATION_CONFIG=$DOT_GRADLE_RELATIVE_PATH/~~APP_LOCATION_CONFIG~~
run_gradle ( ) {
if [ -x "$GRADLE_WRAPPER_RELATIVE_PATH/gradlew" ] ; then
$GRADLE_WRAPPER_RELATIVE_PATH/gradlew "$@"
else
gradle "$@"
fi
}
app_property ( ) {
echo `cat $APP_LOCATION_CONFIG | grep $1 | cut -f2 -d=`
}
# If the app location is not available, set it first via Gradle
if [ ! -f $APP_LOCATION_CONFIG ] ; then
run_gradle -q ~~CACHE_TASK_NAME~~
fi
# Now read in the configuration values for later usage
. $APP_LOCATION_CONFIG
# If the app is not available, download it first via Gradle
if [ ! -f $APP_LOCATION ] ; then
run_gradle -q ~~CACHE_TASK_NAME~~
fi
# If global configuration is disabled which is the default, then
# point the Terraform config to the generated configuration file
# if it exists.
if [ -z $TF_CLI_CONFIG_FILE ] ; then
if [ $USE_GLOBAL_CONFIG == 'false' ] ; then
CONFIG_LOCATION=`app_property configLocation`
if [ -f $CONFIG_LOCATION ] ; then
export TF_CLI_CONFIG_FILE=$CONFIG_LOCATION
else
echo Config location specified as $CONFIG_LOCATION, but file does not exist. >&2
echo Please run the terraformrc Gradle task before using $(basename $0) again >&2
fi
fi
fi
# If we are in a project containing a default Terraform source set
# then point the data directory to the default location.
if [ -z $TF_DATA_DIR ] ; then
if [ -f $PWD/src/tf/main ] ; then
export TF_DATA_DIR=$PWD/build/tf/main
echo $TF_DATA_DIR will be used as data directory >&2
fi
fi
exec $APP_LOCATION "$@"
@REM
@REM ============================================================================
@REM (C) Copyright Schalk W. Cronje 2016 - 2025
@REM
@REM This software is licensed under the Apache License 2.0
@REM See http://www.apache.org/licenses/LICENSE-2.0 for license details
@REM
@REM Unless required by applicable law or agreed to in writing, software
@REM distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
@REM WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
@REM License for the specific language governing permissions and limitations
@REM under the License.
@REM ============================================================================
@REM
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem ~~APP_BASE_NAME~~ wrapper script for Windows
@rem
@rem ##########################################################################
@rem Relative path from this script to the directory where the Gradle wrapper
@rem might be found.
set GRADLE_WRAPPER_RELATIVE_PATH=~~GRADLE_WRAPPER_RELATIVE_PATH~~
@rem Relative path from this script to the project cache dir (usually .gradle).
set DOT_GRADLE_RELATIVE_PATH=~~DOT_GRADLE_RELATIVE_PATH~~
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set APP_LOCATION_CONFIG=%DOT_GRADLE_RELATIVE_PATH%/~~APP_LOCATION_CONFIG~~
@rem If the app location is not available, set it first via Gradle
if not exist %APP_LOCATION_CONFIG% call :run_gradle -q ~~CACHE_TASK_NAME~~
@rem Read settings in from app location properties
@rem - APP_LOCATION
@rem - USE_GLOBAL_CONFIG
@rem - CONFIG_LOCATION
call %APP_LOCATION_CONFIG%
@rem If the app is not available, download it first via Gradle
if not exist %APP_LOCATION% call :run_gradle -q ~~CACHE_TASK_NAME~~
@rem If global configuration is disabled which is the default, then
@rem point the Terraform config to the generated configuration file
@rem if it exists.
if %TF_CLI_CONFIG_FILE% == "" (
if %USE_GLOBAL_CONFIG%==true goto cliconfigset
if exist %CONFIG_LOCATION% (
set TF_CLI_CONFIG_FILE=%CONFIG_LOCATION%
) else (
echo Config location specified as %CONFIG_LOCATION%, but file does not exist. 1>&2
echo Please run the terraformrc Gradle task before using %APP_BASE_NAME% again 1>&2
)
)
:cliconfigset
@rem If we are in a project containing a default Terraform source set
@rem then point the data directory to the default location.
if "%TF_DATA_DIR%" == "" (
if exist %CD%\src\tf\main (
set TF_DATA_DIR=%CD%\build\tf\main
echo %TF_DATA_DIR% will be used as data directory 1>&2
)
)
@rem Execute ~~APP_BASE_NAME~~
%APP_LOCATION% %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
exit /b 0
:run_gradle
if exist %GRADLE_WRAPPER_RELATIVE_PATH%\gradlew.bat (
call %GRADLE_WRAPPER_RELATIVE_PATH%\gradlew.bat %*
) else (
call gradle %*
)
exit /b 0