diff --git a/docs/process.md b/docs/process.md index da84bd9fcc..25772c5585 100644 --- a/docs/process.md +++ b/docs/process.md @@ -1049,43 +1049,49 @@ To sum up, the use of output files with static names over dynamic ones is prefer The `env` qualifier allows you to output a variable defined in the process execution environment: -```groovy -process myTask { - output: - env FOO - - script: - ''' - FOO=$(ls -la) - ''' -} - -workflow { - myTask | view { "directory contents: $it" } -} +```{literalinclude} snippets/process-out-env.nf +:language: groovy ``` +:::{versionchanged} 23.12.0-edge +Prior to this version, if the environment variable contained multiple lines of output, the output would be compressed to a single line by converting newlines to spaces. +::: + (process-stdout)= ### Output type `stdout` The `stdout` qualifier allows you to output the `stdout` of the executed process: -```groovy -process sayHello { - output: - stdout +```{literalinclude} snippets/process-stdout.nf +:language: groovy +``` - """ - echo Hello world! - """ -} +(process-out-eval)= -workflow { - sayHello | view { "I say... $it" } -} +### Output type `eval` + +:::{versionadded} 24.02.0-edge +::: + +The `eval` qualifier allows you to capture the standard output of an arbitrary command evaluated the task shell interpreter context: + +```{literalinclude} snippets/process-out-eval.nf +:language: groovy ``` +Only one-line Bash commands are supported. You can use a semi-colon `;` to specify multiple Bash commands on a single line, and many interpreters can execute arbitrary code on the command line, e.g. `python -c 'print("Hello world!")'`. + +If the command fails, the task will also fail. In Bash, you can append `|| true` to a command to suppress any command failure. + +(process-set)= + +### Output type `set` + +:::{deprecated} 19.08.1-edge +Use `tuple` instead. +::: + (process-out-tuple)= ### Output type `tuple` diff --git a/docs/snippets/process-out-env.nf b/docs/snippets/process-out-env.nf new file mode 100644 index 0000000000..9a80b7382c --- /dev/null +++ b/docs/snippets/process-out-env.nf @@ -0,0 +1,13 @@ +process myTask { + output: + env FOO + + script: + ''' + FOO=$(ls -a) + ''' +} + +workflow { + myTask | view +} \ No newline at end of file diff --git a/docs/snippets/process-out-env.out b/docs/snippets/process-out-env.out new file mode 100644 index 0000000000..390f5c6e56 --- /dev/null +++ b/docs/snippets/process-out-env.out @@ -0,0 +1,8 @@ +. +.. +.command.begin +.command.err +.command.log +.command.out +.command.run +.command.sh \ No newline at end of file diff --git a/docs/snippets/process-out-eval.nf b/docs/snippets/process-out-eval.nf new file mode 100644 index 0000000000..58081aafc7 --- /dev/null +++ b/docs/snippets/process-out-eval.nf @@ -0,0 +1,12 @@ +process sayHello { + output: + eval('bash --version') + + """ + echo Hello world! + """ +} + +workflow { + sayHello | view +} diff --git a/docs/snippets/process-out-eval.out b/docs/snippets/process-out-eval.out new file mode 100644 index 0000000000..9c08b97a7a --- /dev/null +++ b/docs/snippets/process-out-eval.out @@ -0,0 +1,6 @@ +GNU bash, version 5.1.16(1)-release (x86_64-pc-linux-gnu) +Copyright (C) 2020 Free Software Foundation, Inc. +License GPLv3+: GNU GPL version 3 or later + +This is free software; you are free to change and redistribute it. +There is NO WARRANTY, to the extent permitted by law. \ No newline at end of file diff --git a/docs/snippets/process-stdout.nf b/docs/snippets/process-stdout.nf new file mode 100644 index 0000000000..9e2e719896 --- /dev/null +++ b/docs/snippets/process-stdout.nf @@ -0,0 +1,12 @@ +process sayHello { + output: + stdout + + """ + echo Hello world! + """ +} + +workflow { + sayHello | view { "I say... $it" } +} \ No newline at end of file diff --git a/docs/snippets/process-stdout.out b/docs/snippets/process-stdout.out new file mode 100644 index 0000000000..6df702c6e8 --- /dev/null +++ b/docs/snippets/process-stdout.out @@ -0,0 +1,2 @@ +I say... Hello world! + diff --git a/modules/nextflow/src/main/groovy/nextflow/ast/NextflowDSLImpl.groovy b/modules/nextflow/src/main/groovy/nextflow/ast/NextflowDSLImpl.groovy index 89754e8569..7396a5ab16 100644 --- a/modules/nextflow/src/main/groovy/nextflow/ast/NextflowDSLImpl.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/ast/NextflowDSLImpl.groovy @@ -27,6 +27,7 @@ import nextflow.script.BaseScript import nextflow.script.BodyDef import nextflow.script.IncludeDef import nextflow.script.TaskClosure +import nextflow.script.TokenEvalCall import nextflow.script.TokenEnvCall import nextflow.script.TokenFileCall import nextflow.script.TokenPathCall @@ -955,7 +956,7 @@ class NextflowDSLImpl implements ASTTransformation { def nested = methodCall.objectExpression instanceof MethodCallExpression log.trace "convert > output method: $methodName" - if( methodName in ['val','env','file','set','stdout','path','tuple'] && !nested ) { + if( methodName in ['val','env','eval','file','set','stdout','path','tuple'] && !nested ) { // prefix the method name with the string '_out_' methodCall.setMethod( new ConstantExpression('_out_' + methodName) ) fixMethodCall(methodCall) @@ -1123,6 +1124,11 @@ class NextflowDSLImpl implements ASTTransformation { return createX( TokenEnvCall, args ) } + if( methodCall.methodAsString == 'eval' && withinTupleMethod ) { + def args = (TupleExpression) varToStrX(methodCall.arguments) + return createX( TokenEvalCall, args ) + } + /* * input: * tuple val(x), .. from q diff --git a/modules/nextflow/src/main/groovy/nextflow/exception/ProcessEvalException.groovy b/modules/nextflow/src/main/groovy/nextflow/exception/ProcessEvalException.groovy new file mode 100644 index 0000000000..a2babd2b2e --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/exception/ProcessEvalException.groovy @@ -0,0 +1,40 @@ +/* + * Copyright 2013-2023, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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. + * + */ + +package nextflow.exception + +import groovy.transform.CompileStatic + +/** + * Exception thrown when a command output returns a non-zero exit status + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ProcessEvalException extends RuntimeException implements ShowOnlyExceptionMessage { + + String command + String output + int status + + ProcessEvalException(String message, String command, String output, int status) { + super(message) + this.command = command + this.output = output + this.status = status + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/executor/BashWrapperBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/executor/BashWrapperBuilder.groovy index 670a13eacf..dd296c1e0b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/executor/BashWrapperBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/executor/BashWrapperBuilder.groovy @@ -16,6 +16,8 @@ package nextflow.executor +import static java.nio.file.StandardOpenOption.* + import java.nio.file.FileSystemException import java.nio.file.FileSystems import java.nio.file.Files @@ -35,11 +37,7 @@ import nextflow.processor.TaskProcessor import nextflow.processor.TaskRun import nextflow.secret.SecretsLoader import nextflow.util.Escape - -import static java.nio.file.StandardOpenOption.* - import nextflow.util.MemoryUnit - /** * Builder to create the Bash script which is used to * wrap and launch the user task @@ -177,20 +175,76 @@ class BashWrapperBuilder { } } - protected String getOutputEnvCaptureSnippet(List names) { - def result = new StringBuilder() - result.append('\n') - result.append('# capture process environment\n') - result.append('set +u\n') - result.append('cd "$NXF_TASK_WORKDIR"\n') - for( int i=0; i ' : '>> ' ) - result.append(TaskRun.CMD_ENV) - result.append('\n') + /** + * Generate a Bash script to be appended to the task command script + * that takes care of capturing the process output environment variables + * and evaluation commands + * + * @param outEnvs + * The list of environment variables names whose value need to be captured + * @param outEvals + * The set of commands to be evaluated to determine the output value to be captured + * @return + * The Bash script to capture the output environment and eval commands + */ + protected String getOutputEnvCaptureSnippet(List outEnvs, Map outEvals) { + // load the env template + final template = BashWrapperBuilder.class + .getResourceAsStream('command-env.txt') + .newReader() + final binding = Map.of('env_file', TaskRun.CMD_ENV) + final result = new StringBuilder() + result.append( engine.render(template, binding) ) + appendOutEnv(result, outEnvs) + appendOutEval(result, outEvals) + return result.toString() + } + + /** + * Render a Bash script to capture the one or more env variables + * + * @param result A {@link StringBuilder} instance to which append the result Bash script + * @param outEnvs The environment variables to be captured + */ + protected void appendOutEnv(StringBuilder result, List outEnvs) { + if( outEnvs==null ) + outEnvs = List.of() + // out env + for( String key : outEnvs ) { + result << "#\n" + result << "echo $key=\"\${$key[@]}\" >> ${TaskRun.CMD_ENV}\n" + result << "echo /$key/ >> ${TaskRun.CMD_ENV}\n" + } + } + + /** + * Render a Bash script to capture the result of one or more commands + * evaluated in the task script context + * + * @param result + * A {@link StringBuilder} instance to which append the result Bash script + * @param outEvals + * A {@link Map} of key-value pairs modeling the commands to be evaluated; + * where the key represents the environment variable (name) holding the + * resulting output, and the pair value represent the Bash command to be + * evaluated. + */ + protected void appendOutEval(StringBuilder result, Map outEvals) { + if( outEvals==null ) + outEvals = Map.of() + // out eval + for( Map.Entry eval : outEvals ) { + result << "#\n" + result <<"nxf_eval_cmd STDOUT STDERR ${eval.value}\n" + result << 'status=$?\n' + result << 'if [ $status -eq 0 ]; then\n' + result << " echo $eval.key=\"\$STDOUT\" >> ${TaskRun.CMD_ENV}\n" + result << " echo /$eval.key/=exit:0 >> ${TaskRun.CMD_ENV}\n" + result << 'else\n' + result << " echo $eval.key=\"\$STDERR\" >> ${TaskRun.CMD_ENV}\n" + result << " echo /$eval.key/=exit:\$status >> ${TaskRun.CMD_ENV}\n" + result << 'fi\n' } - result.toString() } protected String stageCommand(String stagingScript) { @@ -239,9 +293,16 @@ class BashWrapperBuilder { */ final interpreter = TaskProcessor.fetchInterpreter(script) - if( outputEnvNames ) { - if( !isBash(interpreter) ) throw new IllegalArgumentException("Process output of type env is only allowed with Bash process command -- Current interpreter: $interpreter") - script += getOutputEnvCaptureSnippet(outputEnvNames) + /* + * append to the command script a prolog to capture the declared + * output environment (variable) and evaluation commands + */ + if( outputEnvNames || outputEvals ) { + if( !isBash(interpreter) && outputEnvNames ) + throw new IllegalArgumentException("Process output of type 'env' is only allowed with Bash process scripts -- Current interpreter: $interpreter") + if( !isBash(interpreter) && outputEvals ) + throw new IllegalArgumentException("Process output of type 'eval' is only allowed with Bash process scripts -- Current interpreter: $interpreter") + script += getOutputEnvCaptureSnippet(outputEnvNames, outputEvals) } final binding = new HashMap(20) diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskBean.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskBean.groovy index 4a362d3e9e..c1ef240388 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskBean.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskBean.groovy @@ -73,6 +73,8 @@ class TaskBean implements Serializable, Cloneable { List outputEnvNames + Map outputEvals + String beforeScript String afterScript @@ -144,6 +146,7 @@ class TaskBean implements Serializable, Cloneable { // stats this.outputEnvNames = task.getOutputEnvNames() + this.outputEvals = task.getOutputEvals() this.statsEnabled = task.getProcessor().getSession().statsEnabled this.inputFiles = task.getInputFilesMap() diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy index af2b818e0c..3e332569ab 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy @@ -61,6 +61,7 @@ import nextflow.exception.FailedGuardException import nextflow.exception.IllegalArityException import nextflow.exception.MissingFileException import nextflow.exception.MissingValueException +import nextflow.exception.ProcessEvalException import nextflow.exception.ProcessException import nextflow.exception.ProcessFailedException import nextflow.exception.ProcessRetryableException @@ -84,6 +85,8 @@ import nextflow.script.ScriptMeta import nextflow.script.ScriptType import nextflow.script.TaskClosure import nextflow.script.bundle.ResourcesBundle +import nextflow.script.params.BaseOutParam +import nextflow.script.params.CmdEvalParam import nextflow.script.params.DefaultOutParam import nextflow.script.params.EachInParam import nextflow.script.params.EnvInParam @@ -1079,6 +1082,10 @@ class TaskProcessor { formatTaskError( message, error, task ) break + case ProcessEvalException: + formatCommandError( message, error, task ) + break + case FailedGuardException: formatGuardError( message, error as FailedGuardException, task ) break; @@ -1150,6 +1157,35 @@ class TaskProcessor { return action } + final protected List formatCommandError(List message, ProcessEvalException error, TaskRun task) { + // compose a readable error message + message << formatErrorCause(error) + + // - print the executed command + message << "Command executed:\n" + error.command.stripIndent(true)?.trim()?.eachLine { + message << " ${it}" + } + + // - the exit status + message << "\nCommand exit status:\n ${error.status}" + + // - the tail of the process stdout + message << "\nCommand output:" + def lines = error.output.readLines() + if( lines.size() == 0 ) { + message << " (empty)" + } + for( String it : lines ) { + message << " ${stripWorkDir(it, task.workDir)}" + } + + if( task?.workDir ) + message << "\nWork dir:\n ${task.workDirStr}" + + return message + } + final protected List formatGuardError( List message, FailedGuardException error, TaskRun task ) { // compose a readable error message message << formatErrorCause(error) @@ -1500,6 +1536,10 @@ class TaskProcessor { collectOutEnvParam(task, (EnvOutParam)param, workDir) break + case CmdEvalParam: + collectOutEnvParam(task, (CmdEvalParam)param, workDir) + break + case DefaultOutParam: task.setOutput(param, DefaultOutParam.Completion.DONE) break @@ -1514,10 +1554,11 @@ class TaskProcessor { task.canBind = true } - protected void collectOutEnvParam(TaskRun task, EnvOutParam param, Path workDir) { + protected void collectOutEnvParam(TaskRun task, BaseOutParam param, Path workDir) { // fetch the output value - final val = collectOutEnvMap(workDir).get(param.name) + final outCmds = param instanceof CmdEvalParam ? task.getOutputEvals() : null + final val = collectOutEnvMap(workDir,outCmds).get(param.name) if( val == null && !param.optional ) throw new MissingValueException("Missing environment variable: $param.name") // set into the output set @@ -1527,25 +1568,56 @@ class TaskProcessor { } + /** + * Parse the `.command.env` file which holds the value for `env` and `cmd` + * output types + * + * @param workDir + * The task work directory that contains the `.command.env` file + * @param outEvals + * A {@link Map} instance containing key-value pairs + * @return + */ + @CompileStatic @Memoized(maxCacheSize = 10_000) - protected Map collectOutEnvMap(Path workDir) { + protected Map collectOutEnvMap(Path workDir, Map outEvals) { final env = workDir.resolve(TaskRun.CMD_ENV).text - final result = new HashMap(50) + final result = new HashMap(50) + Matcher matcher + // `current` represent the current capturing env variable name + String current=null for(String line : env.readLines() ) { - def (k,v) = tokenize0(line) - if (!k) continue - result.put(k,v) + // Opening condition: + // line should match a KEY=VALUE syntax + if( !current && (matcher = (line=~/([a-zA-Z_][a-zA-Z0-9_]*)=(.*)/)) ) { + final k = matcher.group(1) + final v = matcher.group(2) + if (!k) continue + result.put(k,v) + current = k + } + // Closing condition: + // line should match /KEY/ or /KEY/=exit_status + else if( current && (matcher = (line=~/\/${current}\/(?:=exit:(\d+))?/)) ) { + final status = matcher.group(1) as Integer ?: 0 + // when exit status is defined and it is a non-zero, it should be interpreted + // as a failure of the execution of the output command; in this case the variable + // holds the std error message + if( outEvals!=null && status ) { + final cmd = outEvals.get(current) + final out = result[current] + throw new ProcessEvalException("Unable to evaluate output", cmd, out, status) + } + // reset current key + current = null + } + else if( current && line!=null) { + result[current] += '\n' + line + } } return result } - private List tokenize0(String line) { - int p=line.indexOf('=') - return p==-1 - ? List.of(line,'') - : List.of(line.substring(0,p), line.substring(p+1)) - } - /** * Collects the process 'std output' * diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy index ab3b6bc0e2..69e217291e 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy @@ -38,6 +38,7 @@ import nextflow.script.BodyDef import nextflow.script.ScriptType import nextflow.script.TaskClosure import nextflow.script.bundle.ResourcesBundle +import nextflow.script.params.CmdEvalParam import nextflow.script.params.EnvInParam import nextflow.script.params.EnvOutParam import nextflow.script.params.FileInParam @@ -587,7 +588,29 @@ class TaskRun implements Cloneable { List getOutputEnvNames() { final items = getOutputsByType(EnvOutParam) - return items ? new ArrayList(items.keySet()*.name) : Collections.emptyList() + if( !items ) + return List.of() + final result = new ArrayList(items.size()) + for( EnvOutParam it : items.keySet() ) { + if( !it.name ) throw new IllegalStateException("Missing output environment name - offending parameter: $it") + result.add(it.name) + } + return result + } + + /** + * @return A {@link Map} instance holding a collection of key-pairs + * where the key represents a environment variable name holding the command + * output and the value the command the executed. + */ + Map getOutputEvals() { + final items = getOutputsByType(CmdEvalParam) + final result = new LinkedHashMap(items.size()) + for( CmdEvalParam it : items.keySet() ) { + if( !it.name ) throw new IllegalStateException("Missing output eval name - offending parameter: $it") + result.put(it.name, it.getTarget(context)) + } + return result } Path getCondaEnv() { diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy index 4808fe97b0..67bac675a7 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy @@ -16,12 +16,13 @@ package nextflow.script +import static nextflow.util.CacheHelper.* + import java.util.regex.Pattern import groovy.transform.PackageScope import groovy.util.logging.Slf4j import nextflow.Const -import nextflow.NF import nextflow.ast.NextflowDSLImpl import nextflow.exception.ConfigParseException import nextflow.exception.IllegalConfigException @@ -30,8 +31,24 @@ import nextflow.executor.BashWrapperBuilder import nextflow.processor.ConfigList import nextflow.processor.ErrorStrategy import nextflow.processor.TaskConfig -import static nextflow.util.CacheHelper.HashMode -import nextflow.script.params.* +import nextflow.script.params.CmdEvalParam +import nextflow.script.params.DefaultInParam +import nextflow.script.params.DefaultOutParam +import nextflow.script.params.EachInParam +import nextflow.script.params.EnvInParam +import nextflow.script.params.EnvOutParam +import nextflow.script.params.FileInParam +import nextflow.script.params.FileOutParam +import nextflow.script.params.InParam +import nextflow.script.params.InputsList +import nextflow.script.params.OutParam +import nextflow.script.params.OutputsList +import nextflow.script.params.StdInParam +import nextflow.script.params.StdOutParam +import nextflow.script.params.TupleInParam +import nextflow.script.params.TupleOutParam +import nextflow.script.params.ValueInParam +import nextflow.script.params.ValueOutParam /** * Holds the process configuration properties @@ -572,6 +589,15 @@ class ProcessConfig implements Map, Cloneable { .bind(obj) } + OutParam _out_eval(Object obj ) { + new CmdEvalParam(this).bind(obj) + } + + OutParam _out_eval(Map opts, Object obj ) { + new CmdEvalParam(this) + .setOptions(opts) + .bind(obj) + } OutParam _out_file( Object obj ) { // note: check that is a String type to avoid to force diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ScriptTokens.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ScriptTokens.groovy index 19a2dfd9a7..72498b2452 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ScriptTokens.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ScriptTokens.groovy @@ -109,6 +109,19 @@ class TokenEnvCall { Object val } +/** + * Token used by the DSL to identify a command output declaration, like this + *
+ *     input:
+ *     tuple( eval(X), ... )
+ *     
+ */
+@ToString
+@EqualsAndHashCode
+@TupleConstructor
+class TokenEvalCall {
+    Object val
+}
 
 /**
  * This class is used to identify a 'val' when used like in this example:
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/CmdEvalParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/CmdEvalParam.groovy
new file mode 100644
index 0000000000..786f3fbaea
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/CmdEvalParam.groovy
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2013-2023, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.
+ *
+ */
+
+package nextflow.script.params
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import groovy.transform.InheritConstructors
+import groovy.transform.Memoized
+
+/**
+ * Model process `output: eval PARAM` definition
+ *
+ * @author Paolo Di Tommaso 
+ */
+@InheritConstructors
+class CmdEvalParam extends BaseOutParam implements OptionalParam {
+
+    private static AtomicInteger counter = new AtomicInteger()
+
+    private Object target
+
+    private int count
+
+    {
+        count = counter.incrementAndGet()
+    }
+
+    String getName() {
+        return "nxf_out_eval_${count}"
+    }
+
+    BaseOutParam bind( def obj ) {
+        if( obj !instanceof CharSequence )
+            throw new IllegalArgumentException("Invalid argument for command output: $this")
+        // the target value object
+        target = obj
+        return this
+    }
+
+    @Memoized
+    String getTarget(Map context) {
+        return target instanceof GString
+            ? target.cloneAsLazy(context).toString()
+            : target.toString()
+    }
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/EnvOutParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/EnvOutParam.groovy
index c94de4edf7..70086a89ba 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/params/EnvOutParam.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/EnvOutParam.groovy
@@ -41,6 +41,12 @@ class EnvOutParam extends BaseOutParam implements OptionalParam {
         if( obj instanceof TokenVar ) {
             this.nameObj = obj.name
         }
+        else if( obj instanceof CharSequence ) {
+            this.nameObj = obj.toString()
+        }
+        else {
+            throw new IllegalArgumentException("Unexpected environment output definition - it should be either a string or a variable identifier - offending value: ${obj?.getClass()?.getName()}")
+        }
 
         return this
     }
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/TupleInParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/TupleInParam.groovy
index 044fdba322..d58a97f925 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/params/TupleInParam.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/TupleInParam.groovy
@@ -17,7 +17,7 @@
 package nextflow.script.params
 
 import groovy.transform.InheritConstructors
-import nextflow.NF
+import nextflow.script.TokenEvalCall
 import nextflow.script.TokenEnvCall
 import nextflow.script.TokenFileCall
 import nextflow.script.TokenPathCall
@@ -76,6 +76,9 @@ class TupleInParam extends BaseInParam {
             else if( item instanceof TokenEnvCall ) {
                 newItem(EnvInParam).bind(item.val)
             }
+            else if( item instanceof TokenEvalCall ) {
+                throw new IllegalArgumentException('Command input declaration is not supported')
+            }
             else if( item instanceof TokenStdinCall ) {
                 newItem(StdInParam)
             }
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/TupleOutParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/TupleOutParam.groovy
index 856d38d22a..91df753b8c 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/params/TupleOutParam.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/TupleOutParam.groovy
@@ -17,7 +17,7 @@
 package nextflow.script.params
 
 import groovy.transform.InheritConstructors
-import nextflow.NF
+import nextflow.script.TokenEvalCall
 import nextflow.script.TokenEnvCall
 import nextflow.script.TokenFileCall
 import nextflow.script.TokenPathCall
@@ -59,6 +59,9 @@ class TupleOutParam extends BaseOutParam implements OptionalParam {
             else if( item instanceof TokenEnvCall ) {
                 create(EnvOutParam).bind(item.val)
             }
+            else if( item instanceof TokenEvalCall ) {
+                create(CmdEvalParam).bind(item.val)
+            }
             else if( item instanceof GString ) {
                 throw new IllegalArgumentException("Unqualified output path declaration is not allowed - replace `tuple \"$item\",..` with `tuple path(\"$item\"),..`")
             }
diff --git a/modules/nextflow/src/main/resources/nextflow/executor/command-env.txt b/modules/nextflow/src/main/resources/nextflow/executor/command-env.txt
new file mode 100644
index 0000000000..8a476122ed
--- /dev/null
+++ b/modules/nextflow/src/main/resources/nextflow/executor/command-env.txt
@@ -0,0 +1,35 @@
+##
+##  Copyright 2013-2023, Seqera Labs
+##
+##  Licensed under the Apache License, Version 2.0 (the "License");
+##  you may not use this file except in compliance with the License.
+##  You may obtain a copy of the License at
+##
+##      http://www.apache.org/licenses/LICENSE-2.0
+##
+##  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.
+
+# capture process environment
+set +u
+set +e
+cd "$NXF_TASK_WORKDIR"
+
+## SYNTAX:
+##   catch STDOUT_VARIABLE STDERR_VARIABLE COMMAND [ARG1[ ARG2[ ...[ ARGN]]]]
+##
+## See solution 7 at https://stackoverflow.com/a/59592881/395921
+##
+nxf_eval_cmd() {
+    {
+        IFS=$'\n' read -r -d '' "${1}";
+        IFS=$'\n' read -r -d '' "${2}";
+        (IFS=$'\n' read -r -d '' _ERRNO_; return ${_ERRNO_});
+    } < <((printf '\0%s\0%d\0' "$(((({ shift 2; "${@}"; echo "${?}" 1>&3-; } | tr -d '\0' 1>&4-) 4>&2- 2>&1- | tr -d '\0' 1>&4-) 3>&1- | exit "$(cat)") 4>&1-)" "${?}" 1>&2) 2>&1)
+}
+
+## reset/create command env file
+echo '' > {{env_file}}
diff --git a/modules/nextflow/src/test/groovy/nextflow/executor/BashWrapperBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/executor/BashWrapperBuilderTest.groovy
index 432d61f9cf..1a084086fc 100644
--- a/modules/nextflow/src/test/groovy/nextflow/executor/BashWrapperBuilderTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/executor/BashWrapperBuilderTest.groovy
@@ -1123,17 +1123,80 @@ class BashWrapperBuilderTest extends Specification {
         def builder = new BashWrapperBuilder()
 
         when:
-        def str = builder.getOutputEnvCaptureSnippet(['FOO','BAR'])
+        def str = builder.getOutputEnvCaptureSnippet(['FOO','BAR'], Map.of())
         then:
         str == '''
             # capture process environment
             set +u
+            set +e
             cd "$NXF_TASK_WORKDIR"
-            echo FOO=${FOO[@]} > .command.env
-            echo BAR=${BAR[@]} >> .command.env
+            
+            nxf_eval_cmd() {
+                {
+                    IFS=$'\\n' read -r -d '' "${1}";
+                    IFS=$'\\n' read -r -d '' "${2}";
+                    (IFS=$'\\n' read -r -d '' _ERRNO_; return ${_ERRNO_});
+                } < <((printf '\\0%s\\0%d\\0' "$(((({ shift 2; "${@}"; echo "${?}" 1>&3-; } | tr -d '\\0' 1>&4-) 4>&2- 2>&1- | tr -d '\\0' 1>&4-) 3>&1- | exit "$(cat)") 4>&1-)" "${?}" 1>&2) 2>&1)
+            }
+            
+            echo '' > .command.env
+            #
+            echo FOO="${FOO[@]}" >> .command.env
+            echo /FOO/ >> .command.env
+            #
+            echo BAR="${BAR[@]}" >> .command.env
+            echo /BAR/ >> .command.env
             '''
             .stripIndent()
+    }
 
+    def 'should return env & cmd capture snippet' () {
+        given:
+        def builder = new BashWrapperBuilder()
+
+        when:
+        def str = builder.getOutputEnvCaptureSnippet(['FOO'], [THIS: 'this --cmd', THAT: 'other --cmd'])
+        then:
+        str == '''
+            # capture process environment
+            set +u
+            set +e
+            cd "$NXF_TASK_WORKDIR"
+            
+            nxf_eval_cmd() {
+                {
+                    IFS=$'\\n' read -r -d '' "${1}";
+                    IFS=$'\\n' read -r -d '' "${2}";
+                    (IFS=$'\\n' read -r -d '' _ERRNO_; return ${_ERRNO_});
+                } < <((printf '\\0%s\\0%d\\0' "$(((({ shift 2; "${@}"; echo "${?}" 1>&3-; } | tr -d '\\0' 1>&4-) 4>&2- 2>&1- | tr -d '\\0' 1>&4-) 3>&1- | exit "$(cat)") 4>&1-)" "${?}" 1>&2) 2>&1)
+            }
+            
+            echo '' > .command.env
+            #
+            echo FOO="${FOO[@]}" >> .command.env
+            echo /FOO/ >> .command.env
+            #
+            nxf_eval_cmd STDOUT STDERR this --cmd
+            status=$?
+            if [ $status -eq 0 ]; then
+              echo THIS="$STDOUT" >> .command.env
+              echo /THIS/=exit:0 >> .command.env
+            else
+              echo THIS="$STDERR" >> .command.env
+              echo /THIS/=exit:$status >> .command.env
+            fi
+            #
+            nxf_eval_cmd STDOUT STDERR other --cmd
+            status=$?
+            if [ $status -eq 0 ]; then
+              echo THAT="$STDOUT" >> .command.env
+              echo /THAT/=exit:0 >> .command.env
+            else
+              echo THAT="$STDERR" >> .command.env
+              echo /THAT/=exit:$status >> .command.env
+            fi
+            '''
+            .stripIndent()
     }
 
     def 'should validate bash interpreter' () {
diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy
index b93fc4ee65..751feeb03f 100644
--- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy
@@ -28,6 +28,7 @@ import nextflow.ISession
 import nextflow.Session
 import nextflow.exception.IllegalArityException
 import nextflow.exception.MissingFileException
+import nextflow.exception.ProcessEvalException
 import nextflow.exception.ProcessException
 import nextflow.exception.ProcessUnrecoverableException
 import nextflow.executor.Executor
@@ -944,18 +945,50 @@ class TaskProcessorTest extends Specification {
         def envFile = workDir.resolve(TaskRun.CMD_ENV)
         envFile.text =  '''
                         ALPHA=one
+                        /ALPHA/
                         DELTA=x=y
+                        /DELTA/
                         OMEGA=
+                        /OMEGA/
+                        LONG=one
+                        two
+                        three
+                        /LONG/=exit:0
                         '''.stripIndent()
         and:
         def processor = Spy(TaskProcessor)
 
         when:
-        def result = processor.collectOutEnvMap(workDir)
+        def result = processor.collectOutEnvMap(workDir, Map.of())
         then:
-        result == [ALPHA:'one', DELTA: "x=y", OMEGA: '']
+        result == [ALPHA:'one', DELTA: "x=y", OMEGA: '', LONG: 'one\ntwo\nthree']
     }
 
+    def 'should parse env map with command error' () {
+        given:
+        def workDir = TestHelper.createInMemTempDir()
+        def envFile = workDir.resolve(TaskRun.CMD_ENV)
+        envFile.text =  '''
+                        ALPHA=one
+                        /ALPHA/
+                        cmd_out_1=Hola
+                        /cmd_out_1/=exit:0
+                        cmd_out_2=This is an error message
+                        for unknown reason
+                        /cmd_out_2/=exit:100
+                        '''.stripIndent()
+        and:
+        def processor = Spy(TaskProcessor)
+
+        when:
+        processor.collectOutEnvMap(workDir, [cmd_out_1: 'foo --this', cmd_out_2: 'bar --that'])
+        then:
+        def e = thrown(ProcessEvalException)
+        e.message == 'Unable to evaluate output'
+        e.command == 'bar --that'
+        e.output == 'This is an error message\nfor unknown reason'
+        e.status == 100
+    }
     def 'should create a task preview' () {
         given:
         def config = new ProcessConfig([cpus: 10, memory: '100 GB'])
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/params/CmdEvalParamTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/params/CmdEvalParamTest.groovy
new file mode 100644
index 0000000000..39cbc26322
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/script/params/CmdEvalParamTest.groovy
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2013-2023, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.
+ */
+
+package nextflow.script.params
+
+import static test.TestParser.*
+
+import test.Dsl2Spec
+/**
+ *
+ * @author Paolo Di Tommaso 
+ */
+class CmdEvalParamTest extends Dsl2Spec {
+
+    def 'should define eval outputs' () {
+        setup:
+        def text = '''
+            process hola {
+              output:
+              eval 'foo --version' 
+              eval "$params.cmd --help"
+              eval "$tool --test"  
+              
+              /echo command/ 
+            }
+            
+            workflow { hola() }
+            '''
+
+        def binding = [params:[cmd:'bar'], tool: 'other']
+        def process = parseAndReturnProcess(text, binding)
+
+        when:
+        def outs = process.config.getOutputs() as List
+
+        then:
+        outs.size() == 3
+        and:
+        outs[0].getName() =~ /nxf_out_eval_\d+/
+        outs[0].getTarget(binding) == 'foo --version'
+        and:
+        outs[1].getName() =~ /nxf_out_eval_\d+/
+        outs[1].getTarget(binding) == 'bar --help'
+        and:
+        outs[2].getName() =~ /nxf_out_eval_\d+/
+        outs[2].getTarget(binding) == 'other --test'
+    }
+
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/params/EnvOutParamTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/params/EnvOutParamTest.groovy
index 827af5ed4f..0fdadef75f 100644
--- a/modules/nextflow/src/test/groovy/nextflow/script/params/EnvOutParamTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/script/params/EnvOutParamTest.groovy
@@ -54,6 +54,35 @@ class EnvOutParamTest extends Dsl2Spec {
 
     }
 
+    def 'should define env outputs with quotes' () {
+        setup:
+        def text = '''
+            process hola {
+              output:
+              env 'FOO'
+              env 'BAR'
+              
+              /echo command/ 
+            }
+            
+            workflow { hola() }
+            '''
+
+        def binding = [:]
+        def process = parseAndReturnProcess(text, binding)
+
+        when:
+        def outs = process.config.getOutputs() as List
+
+        then:
+        outs.size() == 2
+        and:
+        outs[0].name == 'FOO'
+        and:
+        outs[1].name == 'BAR'
+
+    }
+
     def 'should define optional env outputs' () {
         setup:
         def text = '''
@@ -85,4 +114,28 @@ class EnvOutParamTest extends Dsl2Spec {
         out1.getOptional() == true
 
     }
+
+    def 'should handle invalid env definition' () {
+        given:
+        def text = '''
+            process hola {
+              output:
+              env { 0 }
+              
+              /echo command/ 
+            }
+            
+            workflow { hola() }
+            '''
+
+        when:
+        def binding = [:]
+        parseAndReturnProcess(text, binding)
+
+        then:
+        def e = thrown(IllegalArgumentException)
+        and:
+        e.message.startsWith('Unexpected environment output definition')
+
+    }
 }
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/params/TupleOutParamTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/params/TupleOutParamTest.groovy
index 3e40009998..65c9981a37 100644
--- a/modules/nextflow/src/test/groovy/nextflow/script/params/TupleOutParamTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/script/params/TupleOutParamTest.groovy
@@ -178,4 +178,42 @@ class TupleOutParamTest extends Dsl2Spec {
         outs[0].inner[1] instanceof EnvOutParam
         outs[0].inner[1].getName() == 'BAR'
     }
+
+    def 'should create tuple of eval' () {
+        setup:
+        def text = '''
+            process hola {
+              output:
+                tuple eval('this --one'), eval("$other --two")
+              
+              /echo command/ 
+            }
+            
+            workflow {
+              hola()
+            }
+            '''
+
+        def binding = [other:'tool']
+        def process = parseAndReturnProcess(text, binding)
+
+        when:
+        def outs = process.config.getOutputs() as List
+        then:
+        println outs.outChannel
+        outs.size() == 1
+        and:
+        outs[0].outChannel instanceof DataflowVariable
+        and:
+        outs[0].inner.size() == 2
+        and:
+        outs[0].inner[0] instanceof CmdEvalParam
+        outs[0].inner[0].getName() =~ /nxf_out_eval_\d+/
+        (outs[0].inner[0] as CmdEvalParam).getTarget(binding) == 'this --one'
+        and:
+        outs[0].inner[1] instanceof CmdEvalParam
+        outs[0].inner[1].getName() =~ /nxf_out_eval_\d+/
+        (outs[0].inner[1] as CmdEvalParam).getTarget(binding) == 'tool --two'
+
+    }
 }
diff --git a/tests/checks/eval-out.nf/.checks b/tests/checks/eval-out.nf/.checks
new file mode 100644
index 0000000000..b8a38fb379
--- /dev/null
+++ b/tests/checks/eval-out.nf/.checks
@@ -0,0 +1,17 @@
+#
+# run normal mode 
+#
+$NXF_RUN | tee .stdout
+
+[[ `grep INFO .nextflow.log | grep -c 'Submitted process'` == 1 ]] || false
+[[ `< .stdout grep 'GNU bash'` ]] || false
+
+
+#
+# run resume mode 
+#
+$NXF_RUN -resume | tee .stdout
+
+[[ `grep INFO .nextflow.log | grep -c 'Cached process'` == 1 ]] || false
+[[ `< .stdout grep 'GNU bash'` ]] || false
+
diff --git a/tests/eval-out.nf b/tests/eval-out.nf
new file mode 100644
index 0000000000..91fb8ac5b7
--- /dev/null
+++ b/tests/eval-out.nf
@@ -0,0 +1,32 @@
+#!/usr/bin/env nextflow
+/*
+ * Copyright 2013-2023, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.
+ */
+
+process foo {
+    input:
+    val shell
+    output:
+    eval "$shell --version", emit: shell_version
+    '''
+    echo Hello
+    '''
+}
+
+
+workflow {
+  foo('bash')
+  foo.out.shell_version.view{ it.readLines()[0] }
+}