Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 25 additions & 9 deletions src/workflows/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,13 @@ export async function runWorkflowFile({
continue;
}

const command = resolveTemplate(step.command, resolvedArgs, results);
const stdinValue = resolveStdin(step.stdin, resolvedArgs, results);
const env = mergeEnv(ctx.env, workflow.env, step.env, resolvedArgs, results);
const cwd = resolveCwd(step.cwd ?? workflow.cwd, resolvedArgs);
const command = resolveTemplate(step.command, resolvedArgs, results, env);
const stdinValue = resolveStdin(step.stdin, resolvedArgs, results, env);
const rawCwd = resolveCwd(step.cwd ?? workflow.cwd, resolvedArgs, env);
const cwd = rawCwd && !path.isAbsolute(rawCwd)
? path.resolve(path.dirname(resolvedFilePath), rawCwd)
: rawCwd;

const { stdout } = await runShellCommand({ command, stdin: stdinValue, env, cwd });
const json = parseJson(stdout);
Expand Down Expand Up @@ -274,7 +277,7 @@ function mergeEnv(
if (!source) return;
for (const [key, value] of Object.entries(source)) {
if (typeof value === 'string') {
env[key] = resolveTemplate(value, args, results);
env[key] = resolveTemplate(value, args, results, env);
}
}
};
Expand All @@ -283,21 +286,26 @@ function mergeEnv(
return env;
}

function resolveCwd(cwd: string | undefined, args: Record<string, unknown>) {
function resolveCwd(
cwd: string | undefined,
args: Record<string, unknown>,
env?: Record<string, string | undefined>,
) {
if (!cwd) return undefined;
return resolveArgsTemplate(cwd, args);
return resolveArgsTemplate(cwd, args, env);
}

function resolveStdin(
stdin: unknown,
args: Record<string, unknown>,
results: Record<string, WorkflowStepResult>,
env?: Record<string, string | undefined>,
) {
if (stdin === null || stdin === undefined) return null;
if (typeof stdin === 'string') {
const ref = parseStepRef(stdin.trim());
if (ref) return getStepRefValue(ref, results, true);
return resolveTemplate(stdin, args, results);
return resolveTemplate(stdin, args, results, env);
}
return JSON.stringify(stdin);
}
Expand All @@ -306,14 +314,22 @@ function resolveTemplate(
input: string,
args: Record<string, unknown>,
results: Record<string, WorkflowStepResult>,
env?: Record<string, string | undefined>,
) {
const withArgs = resolveArgsTemplate(input, args);
const withArgs = resolveArgsTemplate(input, args, env);
return resolveStepRefs(withArgs, results);
}

function resolveArgsTemplate(input: string, args: Record<string, unknown>) {
function resolveArgsTemplate(
input: string,
args: Record<string, unknown>,
envFallback?: Record<string, string | undefined>,
) {
return input.replace(/\$\{([A-Za-z0-9_-]+)\}/g, (match, key) => {
if (key in args) return String(args[key]);
if (envFallback && key in envFallback && envFallback[key] != null) {
return envFallback[key]!;
}
return match;
});
}
Expand Down
6 changes: 6 additions & 0 deletions test/fixtures/env-test.lobster
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: env-test
env:
TEST_VAR: "${TEST_VAR}"
steps:
- id: test
command: node -e "process.stdout.write(process.env.TEST_VAR || 'undefined')"
153 changes: 153 additions & 0 deletions test/workflow_env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import path from 'node:path';
import os from 'node:os';

import { runWorkflowFile } from '../src/workflows/file.js';

async function runSimpleWorkflow(workflow: object, extraEnv?: Record<string, string>) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-env-'));
const stateDir = path.join(tmpDir, 'state');
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');

const env = { ...process.env, LOBSTER_STATE_DIR: stateDir, ...extraEnv };

const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: 'tool',
},
});

return result;
}

test('env var substitution resolves from process.env', async () => {
const workflow = {
name: 'env-from-process',
env: { MY_TEST_VAR: '${MY_TEST_VAR}' },
steps: [
{
id: 'check',
command: 'node -e "process.stdout.write(process.env.MY_TEST_VAR || \'missing\')"',
},
],
};

const result = await runSimpleWorkflow(workflow, { MY_TEST_VAR: 'hello' });
assert.equal(result.status, 'ok');
assert.deepEqual(result.output, ['hello']);
});

test('workflow-level env overrides parent env', async () => {
const workflow = {
name: 'workflow-override',
env: { MY_VAR: 'workflow' },
steps: [
{
id: 'check',
command: 'node -e "process.stdout.write(process.env.MY_VAR)"',
},
],
};

const result = await runSimpleWorkflow(workflow, { MY_VAR: 'parent' });
assert.equal(result.status, 'ok');
assert.deepEqual(result.output, ['workflow']);
});

test('step-level env overrides workflow-level env', async () => {
const workflow = {
name: 'step-override',
env: { MY_VAR: 'workflow' },
steps: [
{
id: 'check',
command: 'node -e "process.stdout.write(process.env.MY_VAR)"',
env: { MY_VAR: 'step' },
},
],
};

const result = await runSimpleWorkflow(workflow);
assert.equal(result.status, 'ok');
assert.deepEqual(result.output, ['step']);
});

test('args take precedence over env in template substitution', async () => {
const workflow = {
name: 'args-precedence',
args: { NAME: { default: 'arg-value' } },
env: { NAME: '${NAME}' },
steps: [
{
id: 'check',
command: 'node -e "process.stdout.write(process.env.NAME)"',
},
],
};

const result = await runSimpleWorkflow(workflow, { NAME: 'env-value' });
assert.equal(result.status, 'ok');
assert.deepEqual(result.output, ['arg-value']);
});

test('env vars resolve in command templates', async () => {
const workflow = {
name: 'command-template-env',
steps: [
{
id: 'check',
command: 'node -e "process.stdout.write(\'${CMD_VAR}\')"',
},
],
};

const result = await runSimpleWorkflow(workflow, { CMD_VAR: 'resolved' });
assert.equal(result.status, 'ok');
assert.deepEqual(result.output, ['resolved']);
});

test('relative cwd resolves from workflow file directory', async () => {
// Use realpath to resolve macOS /var -> /private/var symlink
const tmpDir = await fsp.realpath(await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-cwd-')));
const scriptsDir = path.join(tmpDir, 'scripts');
await fsp.mkdir(scriptsDir, { recursive: true });

const workflow = {
name: 'relative-cwd',
cwd: './scripts',
steps: [
{
id: 'check',
command: 'node -e "process.stdout.write(process.cwd())"',
},
],
};

const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');

const stateDir = path.join(tmpDir, 'state');
const env = { ...process.env, LOBSTER_STATE_DIR: stateDir };

const result = await runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env,
mode: 'tool',
},
});

assert.equal(result.status, 'ok');
assert.deepEqual(result.output, [scriptsDir]);
});