diff --git a/src/workflows/file.ts b/src/workflows/file.ts index be17fee..0b2cdb6 100644 --- a/src/workflows/file.ts +++ b/src/workflows/file.ts @@ -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); @@ -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); } } }; @@ -283,21 +286,26 @@ function mergeEnv( return env; } -function resolveCwd(cwd: string | undefined, args: Record) { +function resolveCwd( + cwd: string | undefined, + args: Record, + env?: Record, +) { if (!cwd) return undefined; - return resolveArgsTemplate(cwd, args); + return resolveArgsTemplate(cwd, args, env); } function resolveStdin( stdin: unknown, args: Record, results: Record, + env?: Record, ) { 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); } @@ -306,14 +314,22 @@ function resolveTemplate( input: string, args: Record, results: Record, + env?: Record, ) { - const withArgs = resolveArgsTemplate(input, args); + const withArgs = resolveArgsTemplate(input, args, env); return resolveStepRefs(withArgs, results); } -function resolveArgsTemplate(input: string, args: Record) { +function resolveArgsTemplate( + input: string, + args: Record, + envFallback?: Record, +) { 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; }); } diff --git a/test/fixtures/env-test.lobster b/test/fixtures/env-test.lobster new file mode 100644 index 0000000..65b0338 --- /dev/null +++ b/test/fixtures/env-test.lobster @@ -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')" diff --git a/test/workflow_env.test.ts b/test/workflow_env.test.ts new file mode 100644 index 0000000..6e12ae5 --- /dev/null +++ b/test/workflow_env.test.ts @@ -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) { + 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]); +});