Tasks
Tasks are a project-level feature to define a project command system backed by shell scripts. Tasks are used to implement development workflows and are accessible through the projen CLI as subcommands.
The following example defines a task named "hello" which runs echo with the
argument hello, world!:
const hello = project.addTask("hello");
hello.execArgs(["echo", "hello, world!"]);
Run pnpm dlx projen and the task will be available in the CLI:
pnpm dlx projen hello
๐ค hello | echo hello, world!
hello, world!
execArgs takes the program followed by its arguments as a list. Each element
is passed to the program exactly as written, so arguments that contain spaces
or other special characters don't need to be quoted or escaped. This is the
recommended way to define command steps.
If you'd rather write a full shell command line โ for example to use
environment variable expansion ($FOO), pipes (|), redirects (>), or
chaining (&&) โ use exec with a single string instead:
hello.exec("echo hello, world!");
You can also define some metadata and the first step declaratively:
const projen = require("projen");
const hello = project.addTask("hello", {
description: "say hello",
category: projen.tasks.TaskCategory.TEST,
execArgs: ["echo", "hello, world!"],
});
Stepsโ
Tasks can include any number of steps:
hello.execArgs(["echo", "step number 2"]);
// a name can be added to a step if desired
hello.execArgs(["echo", "foo bar"], { name: 'print the text "foo bar"' });
The --inspect option can be used to display the contents of a task:
projen hello --inspect
echo hello, world!
echo step number 2
echo foo bar
If a step fails, the task will fail and all subsequent steps will not be executed.
You can also add steps to the beginning of a task:
const hello = project.addTask("hello");
hello.exec("echo hello");
hello.prependExec("echo world");
Then:
projen hello 2> /dev/null
world
hello
Subtasksโ
Tasks can also spawn sub-tasks as a step:
const world = project.addTask("world");
world.execArgs(["echo", "world!"]);
const hello = project.addTask("hello");
hello.execArgs(["echo", "hello"]);
hello.spawn(world);
The output will be:
projen hello
๐ค hello | echo hello
hello
๐ค hello ยป world | echo world!
world!
projen hello --inspect
echo hello
world:
echo world!
Environmentโ
Environment variables can be defined at the project level (for all tasks), the task level, or the task step level:
project.tasks.addEnvironment("FOO", "hello");
const hello = project.addTask("hello");
hello.env("BAR", "beautiful");
To use environment variables directly in tasks, you must use exec because
execArgs does not perform shell expansion:
hello.exec("echo $FOO, $BAR $BAZ!", { env: { BAZ: "world" } });
Then:
projen hello
๐ค hello | echo $FOO, $BAR $BAZ!
hello, beautiful world!
You can also evaluate environment variable values from a shell command:
const hello = project.addTask("hello");
hello.env("TIME", "$(date)");
hello.exec("echo current time is $TIME");
Then:
projen hello
๐ค hello | echo current time is $TIME
current time is Tue Dec 1 09:32:33 IST 2020
Conditionsโ
The condition option includes a command that determines if the task is
executed. If the command exits successfully (with a zero exit code), steps will
be executed. Otherwise, the task will be skipped (successfully).
const hello = project.addTask("hello", {
condition: '[ -n "$CI" ]', // only execute if the CI environment variable is defined
exec: "echo running in a CI environment",
});
Then:
projen hello
๐ค hello | condition: [ -n "$CI" ]
๐ค hello | condition exited with non-zero - skipping
CI=1 projen hello
๐ค hello | condition: [ -n "$CI" ]
๐ค hello | echo running in a CI environment
running in a CI environment
The condition option can also be specified on individual task steps, for more
granular control over task execution behavior:
const hello = project.addTask("hello", {
steps: [
{ exec: "running in a CI environment", condition: '[ -n "$CI" ]' },
{ exec: "not running in a CI environment", condition: '[ ! -n "$CI" ]' },
],
});
Then:
projen hello
๐ค hello | condition: [ -n "$CI" ]
๐ค hello | condition exited with non-zero - skipping
๐ค hello | condition: [ ! -n "$CI" ]
๐ค hello | echo not running in a CI environment
not running in a CI environment
CI=1 projen hello
๐ค hello | condition: [ -n "$CI" ]
๐ค hello | echo running in a CI environment
running in a CI environment
๐ค hello | condition: [ ! -n "$CI" ]
๐ค hello | condition exited with non-zero - skipping
Shellโ
Every task command - each step's exec, the task or step condition, and any
$(...) environment-variable evaluation - is interpreted by a shell. By default
that is projen's built-in cross-platform shell, powered by
dax: it understands POSIX-style syntax
(&&, |, $VAR, globs, redirects, ...) and ships cross-platform versions of
common commands (mkdir -p, rm -rf, cp, ...), so the same task definitions
run identically on Linux, macOS, and Windows with no Unix-like shell installed.
Any other program is run from your PATH, so a task that calls a tool that
isn't built in (e.g. grep, sed) is only portable to platforms where that
tool exists. Notably, many such tools are absent on Windows.
See dax's built-in commands for the full list.
Choosing a different shellโ
A task's shell is a TaskShell. Pick a built-in shell, or provide an explicit
invocation:
TaskShell.projen()(the default) - the built-in cross-platform shell described above.TaskShell.system()- the operating system's native shell (/bin/shon POSIX,cmd.exeon Windows). Use this to opt out of the cross-platform shell and run through whatever shell the host provides.TaskShell.bash()/TaskShell.sh()- convenience helpers forbash -candsh -c.TaskShell.command([...])- an arbitrary invocation given as an argument list, with the command appended as the final argument, e.g.TaskShell.command(["yarn", "exec"]).
The shell can be set at three levels - project, task, and step - and the nearest declared level wins (a scalar override, not merged):
import { TaskShell } from "projen";
// 1. project default - applies to every task
project.tasks.shell = TaskShell.bash();
// 2. per task
const hello = project.addTask("hello", {
shell: TaskShell.bash(),
exec: "echo hello from bash",
});
// 3. per step - overrides the task/project shell
hello.exec("echo hello from sh", { shell: TaskShell.sh() });
If none is set, the built-in projen shell is used.
Strings vs. argument listsโ
The shell applies to both ways of writing a command, but differently:
- A string command (
exec, acondition, or a$(...)value) is a command line, so the shell interprets it - its&&,|,$VAR, redirects, etc. are the shell's to handle. - An
execArgsargument list is never parsed by a shell - each element reaches the program exactly as written, whatever theshell. Theshellonly decides how the program is launched:projenandsystemspawn it directly, while an invocation such as["npx", "--no", "-c"]runs it through that tool, so it inherits that tool's environment (e.g.npxresolving a locally installed binary).
So a step can combine the two - for example, run a binary inside a package manager's environment while still passing arguments as a list:
project.addTask("synth", {
// run through npx, but pass the program + args as a list (no quoting)
shell: TaskShell.command(["npx", "--no", "-c"]),
execArgs: ["ts-node", "--project", "tsconfig.json", ".projenrc.ts"],
});
When to declare a shellโ
Prefer the built-in TaskShell.projen() for portability. It is the default
and you normally don't need to specify it.
Reach for an explicit invocation when a task relies on features the built-in
shell does not implement. For example bash-specific syntax or a command that
must run inside a package manager's environment (TaskShell.command(["yarn", "exec"])).
TaskShell.system() is available if you want to opt-out of projen handling
shell selection for you. You can use it to restore the behavior of older projen versions.
Tasks as npm scriptsโ
By default, npm scripts in NodeProjects (or derivatives) are implemented by delegating the
command to the projen CLI:
{
"scripts": {
"compile": "pnpm dlx projen compile"
}
}
This means that when yarn compile or npm run compile are executed, the
projen CLI will be invoked and the task will be executed.
You can see a list of all steps in a task from the command line by passing
the --inspect flag, e.g. yarn compile --inspect.
Overriding Tasksโ
The Project-defined tasks can be overridden by the user using the reset() method. For example, the user may prefer
running tests on a Python project using nose2 instead of pytest, and the current PythonProject
implementation uses pytest by default. The user can override the test task to use nose2 instead:
const testTask = project.tasks.tryFind("test");
if (testTask) {
testTask.reset("nose2 tests/");
}
If you're using .projenrc.py, the example would look like this:
test_task = project.tasks.try_find("test")
if test_task:
test_task.reset("nose2 tests/", receive_args=True) # Passes through any arguments passed to the task
Extending Tasksโ
Similar to overriding tasks, you can extend tasks by adding additional steps to the end of the task:
project.tasks.tryFind("build")?.execArgs(["echo", "Build completed successfully."]);
Saying thingsโ
There is an additional say step that can be used to print a message to the console:
project.tasks.tryFind("build")!.say("Build completed successfully.");
Once the task is complete, an additional message will be printed to the console:
... other build steps here
๐พ build | Build completed successfully.
Patching an existing task vs. creating a new taskโ
The choice between patching an existing task and creating a new task is largely personal or organizational preference. However, there are some general guidelines:
- If you're adding a new task that is conceptually similar to an existing task, it's probably
best to patch the existing task. For example, if you're running tests using
pytestand also leveraging Cypress for end-to-end testing, it's probably best to add a new step to the existingtesttask to run Cypress tests. - If you're combining tasks that already exist with a new series of tasks that are not very
similar, it is probably best to add a new task. An example would be if you have specific linters
you run during CI, you may want to create a
linttask.- The exception to the above guideline is when you want to run everything as part of an existing
CI/CD pipeline. In that case, you may want to use the existing
buildtask to spawn a a customcitask, or simply add steps. It's largely up to your preference, but keep in mind the default CI/CD workflows for projen GitHub projects already executesbuild.
- The exception to the above guideline is when you want to run everything as part of an existing
CI/CD pipeline. In that case, you may want to use the existing