diff --git a/.claude/unit-testing-jobs.md b/.claude/unit-testing-jobs.md
new file mode 100644
index 000000000..8c7db06af
--- /dev/null
+++ b/.claude/unit-testing-jobs.md
@@ -0,0 +1,157 @@
+# Unit Testing Job Code
+
+OpenFn job expressions are not valid JavaScript out of the box — top-level adaptor calls like `get('/endpoint')` prevent them from being imported directly into a test runner. Compiling them first solves this.
+
+## Approach
+
+1. Compile job expressions to standard JavaScript
+2. Import compiled files in your test suite
+3. Test any pure functions in isolation
+
+## Compiling for Tests
+
+`openfn compile` writes compiled output to `dist/` by default (as `.mjs` files). Use `--exports-only` to also strip adaptor operation calls, keeping only explicitly exported code.
+
+```bash
+# Compile all workflows in the project (full compilation, preserves operations)
+openfn compile
+
+# Compile all workflows, stripping operation calls (useful for unit testing)
+openfn compile --exports-only
+
+# Compile a single workflow by name
+openfn compile my-workflow
+
+# Compile a single workflow to a custom directory
+openfn compile my-workflow -o tests/
+
+# Compile a single job expression (prints to stdout)
+openfn compile workflows/my-workflow/step-a.js --exports-only
+
+# Watch mode: recompile whenever source files change
+openfn compile --exports-only --watch
+
+# Remove the output folder before compiling
+openfn compile --exports-only --clean
+
+# Compile a project in another directory
+openfn compile --workspace path/to/project
+```
+
+## What `--exports-only` keeps
+
+`--exports-only` strips operation calls. Only explicitly exported declarations survive:
+
+- `export const myHelper = ...` ✓
+- `export function parseSms() {}` ✓
+- `const helper = ...` (not exported) ✗ — dropped
+- `fn(state => ...)` (operation call) ✗ — stripped
+
+`export default` is removed in strip mode — it is only needed by the runtime, not for unit testing.
+
+Steps whose compiled output is empty after stripping are skipped — no file is written.
+
+## Full compilation (no `--exports-only`)
+
+Without `--exports-only`, the full compiled output is written — all declarations are preserved and operations are kept in `export default [op1, op2, ...]`:
+
+```js
+import { post } from '@openfn/language-http';
+
+export default [post('/endpoint', { data: state.data })];
+```
+
+All steps are written regardless of whether they export anything.
+
+## Example: Testing a Helper Function
+
+**Source** (`workflows/dhis2-sync/transform.js`):
+
+```js
+import { dateFns } from '@openfn/language-dhis2';
+
+export const formatDate = (date) => dateFns.format(date, 'yyyy-MM-dd');
+
+fn((state) => ({
+ ...state,
+ data: state.data.map((row) => ({
+ ...row,
+ date: formatDate(row.date),
+ })),
+}));
+```
+
+**Compiled** (`dist/dhis2-sync/transform.mjs`) after `openfn compile --exports-only`:
+
+```js
+import { dateFns } from '@openfn/language-dhis2';
+
+export const formatDate = (date) => dateFns.format(date, 'yyyy-MM-dd');
+```
+
+The operation is stripped. `formatDate` survives because it is explicitly exported.
+
+**Test** (using any test runner):
+
+```js
+// test/transform.test.js
+import { formatDate } from '../dist/dhis2-sync/transform.mjs';
+
+test('formats a date correctly', () => {
+ const result = formatDate(new Date('2024-01-15'));
+ assert.equal(result, '2024-01-15');
+});
+```
+
+## Project-wide Compilation
+
+Running `openfn compile` with no path compiles every step in every workflow in the current project directory (must contain `openfn.yaml`).
+
+Output layout:
+
+```
+dist/
+ my-workflow/
+ step-a.mjs
+ step-b.mjs
+ another-workflow/
+ step-c.mjs
+```
+
+Override the output directory with `-o
`:
+
+```bash
+openfn compile --exports-only -o tests/
+```
+
+Configure default directories in `openfn.yaml`:
+
+```yaml
+dirs:
+ workflows: workflows
+ compiled: dist # output dir used by openfn compile
+```
+
+## Recommended Setup
+
+**`package.json`** (in your OpenFn project):
+
+```json
+{
+ "scripts": {
+ "compile": "openfn compile --exports-only",
+ "compile:watch": "openfn compile --exports-only --watch",
+ "test": "node --test test/**/*.test.js"
+ }
+}
+```
+
+## Notes
+
+- In `--exports-only` mode, only `export const` and `export function` declarations survive — non-exported helpers are always dropped
+- Import statements are always preserved
+- Without `--exports-only`, all code including operations is kept in `export default [...]`
+- Watch mode reruns compilation on any source change, making the edit → test cycle fast
+- Use `-O` to print compiled output to stdout instead of writing to disk
+- Output files use the `.mjs` extension, so Node always treats them as ES modules — no `"type": "module"` needed in your project's `package.json`
+- Use `--clean` to remove the output folder before compiling, and `--workspace ` to target a project outside the current directory
diff --git a/CLAUDE.md b/CLAUDE.md
index 890774414..5e314906f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -134,6 +134,7 @@ The [.claude](.claude) folder contains detailed guides:
- **[event-processor.md](.claude/event-processor.md)** - Worker event processing deep-dive (ordering, batching) — companion to `packages/ws-worker/CLAUDE.md`
- **[yaml-formats.md](.claude/yaml-formats.md)** - v1 vs v2 project YAML formats: structure, detection logic, and conversion paths
+- **[unit-testing-jobs.md](.claude/unit-testing-jobs.md)** - How to compile job for unit-testing
Key packages also carry their own `CLAUDE.md` (runtime, engine-multi, ws-worker), auto-loaded when you work in them.
diff --git a/integration-tests/cli/CHANGELOG.md b/integration-tests/cli/CHANGELOG.md
index 3a00f36b2..f5169ce86 100644
--- a/integration-tests/cli/CHANGELOG.md
+++ b/integration-tests/cli/CHANGELOG.md
@@ -1,5 +1,13 @@
# @openfn/integration-tests-cli
+## 1.0.26
+
+### Patch Changes
+
+- Updated dependencies [ac44559]
+ - @openfn/project@0.18.0
+ - @openfn/lightning-mock@2.4.24
+
## 1.0.25
### Patch Changes
diff --git a/integration-tests/cli/package.json b/integration-tests/cli/package.json
index 4bf6b243a..6b5890c57 100644
--- a/integration-tests/cli/package.json
+++ b/integration-tests/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@openfn/integration-tests-cli",
"private": true,
- "version": "1.0.25",
+ "version": "1.0.26",
"description": "CLI integration tests",
"author": "Open Function Group ",
"license": "ISC",
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 117d78cdb..6d3e237b4 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,26 @@
# @openfn/cli
+## 1.39.0
+
+### Minor Changes
+
+- ac44559: Improve `openfn compile` for unit-testing job code
+
+ - Add `--exports-only` to strip adaptor operation calls, keeping only exported declarations
+ - Compile whole projects (or a single workflow by name) to `dist/` as `.mjs` files
+ - Add `--clean` to remove the output folder before compiling
+ - Add `--workspace` support (as in `openfn execute` and `openfn project`)
+ - Support a `dirs.compiled` key in the workspace config (openfn.yaml) to set the output folder
+ - Watch mode now respects the configured workflows and output directories
+
+### Patch Changes
+
+- bec2581: Fix for cleaner output to stdout
+- Updated dependencies [ac44559]
+ - @openfn/compiler@1.3.0
+ - @openfn/lexicon@2.4.0
+ - @openfn/project@0.18.0
+
## 1.38.5
### Patch Changes
diff --git a/packages/cli/package.json b/packages/cli/package.json
index e1c5a7120..ee7991da2 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@openfn/cli",
- "version": "1.38.5",
+ "version": "1.39.0",
"description": "CLI devtools for the OpenFn toolchain",
"engines": {
"node": ">=18",
@@ -60,6 +60,7 @@
"@openfn/project": "workspace:^",
"@openfn/runtime": "workspace:*",
"chalk": "^5.6.2",
+ "chokidar": "^3.6.0",
"dotenv": "^17.3.1",
"dotenv-expand": "^12.0.3",
"figures": "^5.0.0",
diff --git a/packages/cli/src/compile/command.ts b/packages/cli/src/compile/command.ts
index b0f9029ac..527b8893e 100644
--- a/packages/cli/src/compile/command.ts
+++ b/packages/cli/src/compile/command.ts
@@ -1,6 +1,7 @@
import yargs from 'yargs';
import { Opts } from '../options';
import * as o from '../options';
+import * as po from '../projects/options';
import { build, ensure, override } from '../util/command-builders';
export type CompileOptions = Pick<
@@ -8,8 +9,10 @@ export type CompileOptions = Pick<
| 'adaptors'
| 'command'
| 'expandAdaptors'
+ | 'exportsOnly'
| 'ignoreImports'
| 'expressionPath'
+ | 'planPath'
| 'logJson'
| 'log'
| 'outputPath'
@@ -19,39 +22,46 @@ export type CompileOptions = Pick<
| 'useAdaptorsMonorepo'
| 'globals'
| 'trace'
+ | 'watch'
+ | 'workflowName'
> & {
workflow?: Opts['workflow'];
repoDir?: string;
+ workspace?: string;
+ clean?: boolean;
};
const options = [
o.expandAdaptors, // order important
o.adaptors,
+ override(po.clean, {
+ description: 'Remove the output folder before compiling',
+ }),
+ o.exportsOnly,
o.ignoreImports,
o.inputPath,
o.log,
o.logJson,
- override(o.outputStdout, {
- default: true,
- }),
+ o.outputStdout,
o.outputPath,
o.repoDir,
o.trace,
o.useAdaptorsMonorepo,
+ o.watchFlag,
o.workflow,
+ po.workspace,
];
const compileCommand: yargs.CommandModule = {
command: 'compile [path]',
describe:
- 'Compile an openfn job or workflow and print or save the resulting JavaScript.',
+ 'Compile an openfn job, workflow, or whole project and print or save the resulting JavaScript.',
handler: ensure('compile', options),
builder: (yargs) =>
build(options, yargs)
.positional('path', {
describe:
- 'The path to load the job or workflow from (a .js or .json file or a dir containing a job.js file)',
- demandOption: true,
+ 'Path to a .js expression, .json/.yaml workflow, or a project directory. Omit to compile all workflows in the current project.',
})
.example(
'compile foo/job.js',
@@ -59,8 +69,37 @@ const compileCommand: yargs.CommandModule = {
)
.example(
'compile foo/workflow.json -o foo/workflow-compiled.json',
- 'Compiles the workflow at foo/work.json and prints the result to -o foo/workflow-compiled.json'
- ),
+ 'Compiles the workflow and writes to the given path'
+ )
+ .example(
+ 'compile',
+ 'Compiles all workflows in the current project and writes JS files to dist/'
+ )
+ .example(
+ 'compile my-workflow',
+ 'Compiles a single workflow by name and writes JS files to dist/'
+ )
+ .example(
+ 'compile my-workflow -O',
+ 'Compiles a workflow and prints to stdout'
+ )
+ .example(
+ 'compile foo/job.js --exports-only',
+ 'Strips adaptor operation calls, keeping only exported declarations'
+ )
+ .example(
+ 'compile --exports-only',
+ 'Compiles entire project to dist/ stripping operation calls'
+ )
+ .example(
+ 'compile --clean',
+ 'Removes the output folder before compiling the project'
+ )
+ .example(
+ 'compile foo/job.js --watch',
+ 'Watches the file and recompiles on every change'
+ )
+ .example('compile --watch', 'Compiles all workflows on change'),
};
export default compileCommand;
diff --git a/packages/cli/src/compile/compile.ts b/packages/cli/src/compile/compile.ts
index 822188702..01c98837b 100644
--- a/packages/cli/src/compile/compile.ts
+++ b/packages/cli/src/compile/compile.ts
@@ -1,10 +1,15 @@
+import path from 'node:path';
+import fs from 'node:fs/promises';
+import { rimraf } from 'rimraf';
import compile, {
preloadAdaptorExports,
Options,
getExports,
+ transformers as t,
} from '@openfn/compiler';
import { getModulePath, type ExecutionPlan, type Job } from '@openfn/runtime';
import type { SourceMapWithOperations } from '@openfn/lexicon';
+import { Workspace } from '@openfn/project';
import createLogger, { COMPILER, Logger } from '../util/logger';
import abort from '../util/abort';
@@ -52,11 +57,15 @@ const compileJob = async (
jobName?: string
): Promise => {
try {
+ let transformers: any = undefined;
const compilerOptions: Options = await loadTransformOptions(opts, log);
+ if (opts.exportsOnly) {
+ transformers = [t.exportsOnly, t.lazyState, t.promises, t.addImports];
+ }
if (jobName) {
compilerOptions.name = jobName;
}
- return compile(job, compilerOptions);
+ return compile(job, compilerOptions, transformers);
} catch (e: any) {
abort(
log,
@@ -69,7 +78,6 @@ const compileJob = async (
}
};
-// Find every expression in the job and run the compiler on it
const compileWorkflow = async (
plan: ExecutionPlan,
opts: CompileOptions,
@@ -110,7 +118,6 @@ export const stripVersionSpecifier = (specifier: string) => {
return specifier;
};
-// Take a module path as provided by the CLI and convert it into a path
export const resolveSpecifierPath = async (
pattern: string,
repoDir: string | undefined,
@@ -119,7 +126,6 @@ export const resolveSpecifierPath = async (
const [specifier, path] = pattern.split('=');
if (path) {
- // given an explicit path, just load it.
log.debug(`Resolved ${specifier} to path: ${path}`);
return path;
}
@@ -131,7 +137,6 @@ export const resolveSpecifierPath = async (
return null;
};
-// Mutate the opts object to write export information for the add-imports transformer
export const loadTransformOptions = async (
opts: CompileOptions,
log: Logger
@@ -140,15 +145,13 @@ export const loadTransformOptions = async (
logger: log || createLogger(COMPILER, opts as any),
trace: opts.trace,
};
- // If an adaptor is passed in, we need to look up its declared exports
- // and pass them along to the compiler
+
if (opts.adaptors?.length && opts.ignoreImports != true) {
const adaptorsConfig = [];
for (const adaptorInput of opts.adaptors) {
let exports;
const [specifier] = adaptorInput.split('=');
- // Preload exports from a path, optionally logging errors in case of a failure
log.debug(`Trying to preload types for ${specifier}`);
const path = await resolveSpecifierPath(adaptorInput, opts.repoDir, log);
if (path) {
@@ -179,3 +182,93 @@ export const loadTransformOptions = async (
return options;
};
+
+export const compileProject = async (
+ opts: CompileOptions,
+ log: Logger,
+ workspacePath: string,
+ workflowFilter?: string
+): Promise => {
+ // validate=false suppresses warnings when workspace config has no extra metadata
+ const workspace = new Workspace(workspacePath, log as any, false);
+ const project = await workspace.getCheckedOutProject();
+
+ if (!project) {
+ log.error(
+ 'No project found. Run from a directory containing openfn.yaml, or provide a path.'
+ );
+ process.exit(1);
+ }
+
+ const wsConfig = workspace.getConfig();
+
+ const compiledDir = opts.outputStdout
+ ? null
+ : path.resolve(
+ workspacePath,
+ opts.outputPath ?? wsConfig.dirs?.compiled ?? 'dist'
+ );
+
+ if (compiledDir) {
+ if (opts.clean) {
+ log.info(`Cleaning ${compiledDir}`);
+ await rimraf(compiledDir);
+ }
+ log.info(`Compiling project to ${compiledDir}`);
+ }
+
+ let workflows = project.workflows;
+ if (workflowFilter) {
+ workflows = workflows.filter(
+ (wf: any) => wf.id === workflowFilter || wf.name === workflowFilter
+ );
+ if (workflows.length === 0) {
+ log.error(`Workflow '${workflowFilter}' not found in project.`);
+ process.exit(1);
+ }
+ }
+
+ const outPaths: string[] = [];
+
+ const allSteps = workflows.flatMap((wf: any) =>
+ wf.steps
+ .filter((step: any) => step.expression)
+ .map((step: any) => ({ workflow: wf, step }))
+ );
+
+ for (const { workflow, step } of allSteps) {
+ const stepOpts: CompileOptions = {
+ ...opts,
+ adaptors: step.adaptor ? [step.adaptor] : opts.adaptors ?? [],
+ };
+
+ const { code } = await compileJob(
+ step.expression,
+ stepOpts,
+ log,
+ step.name ?? step.id
+ );
+
+ const stepId = `${workflow.id}/${step.id}`;
+
+ if (opts.exportsOnly && !code.trim()) {
+ log.debug(` ${stepId} — skipped (empty after stripping)`);
+ continue;
+ }
+
+ if (opts.outputStdout) {
+ log.success(`// ${stepId}\n\n` + code);
+ } else {
+ const outPath = path.join(compiledDir!, workflow.id, `${step.id}.mjs`);
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
+ await fs.writeFile(outPath, code);
+ outPaths.push(outPath);
+ log.info(`Compiled ${stepId} to ${outPath}`);
+ }
+ }
+
+ if (!opts.outputStdout) {
+ log.success(`Compiled ${outPaths.length} step(s) to ${compiledDir}`);
+ }
+ return outPaths;
+};
diff --git a/packages/cli/src/compile/handler.ts b/packages/cli/src/compile/handler.ts
index 4c75415cc..6c289ad6b 100644
--- a/packages/cli/src/compile/handler.ts
+++ b/packages/cli/src/compile/handler.ts
@@ -1,16 +1,44 @@
-import { mkdir, writeFile } from 'node:fs/promises';
-import { dirname } from 'node:path';
+import path from 'node:path';
+import { writeFile, mkdir } from 'node:fs/promises';
+import chokidar from 'chokidar';
+import { Workspace } from '@openfn/project';
import type { CompileOptions } from './command';
import type { Logger } from '../util/logger';
-import compile from './compile';
+import compile, { compileProject } from './compile';
import loadPlan from '../util/load-plan';
import assertPath from '../util/assert-path';
-const compileHandler = async (options: CompileOptions, logger: Logger) => {
- assertPath(options.path);
+// planPath/expressionPath are set by the input-path option when options.path
+// looks like a file; a bare workflow name sets neither
+const isFileInput = (options: CompileOptions) =>
+ Boolean(options.planPath || options.expressionPath);
+
+const collectWatchOptions = (options: CompileOptions, logger: Logger) => {
+ const ignored = ['**/node_modules/**'];
- let result;
+ if (options.expressionPath) {
+ return { targets: [path.resolve(options.expressionPath)], ignored };
+ }
+ if (options.path && isFileInput(options)) {
+ return { targets: [path.resolve(options.path)], ignored };
+ }
+
+ // Project mode: the output dir must be ignored, or writing compiled files
+ // would retrigger the watcher
+ const workspace = new Workspace(options.workspace!, logger as any, false);
+ const outDir = path.resolve(
+ options.workspace!,
+ options.outputPath ?? workspace.getConfig().dirs?.compiled ?? 'dist'
+ );
+ return {
+ targets: [path.join(workspace.workflowsPath, '**', '*.js')],
+ ignored: [...ignored, path.join(outDir, '**')],
+ };
+};
+
+const doCompile = async (options: CompileOptions, logger: Logger) => {
+ let result: string;
if (options.expressionPath) {
const { code } = await compile(options.expressionPath, options, logger);
result = code;
@@ -20,13 +48,53 @@ const compileHandler = async (options: CompileOptions, logger: Logger) => {
result = JSON.stringify(compiledPlan, null, 2);
}
- if (options.outputStdout) {
+ if (options.outputPath) {
+ await mkdir(path.dirname(options.outputPath), { recursive: true });
+ await writeFile(options.outputPath, result);
+ logger.success(`Compiled to ${options.outputPath}`);
+ } else {
logger.success('Result:\n\n' + result);
+ }
+};
+
+const runCompile = async (options: CompileOptions, logger: Logger) => {
+ if (isFileInput(options)) {
+ assertPath(options.expressionPath ?? options.planPath);
+ await doCompile(options, logger);
} else {
- await mkdir(dirname(options.outputPath!), { recursive: true });
- await writeFile(options.outputPath!, result as string);
- logger.success(`Compiled to ${options.outputPath}`);
+ await compileProject(
+ options,
+ logger,
+ options.workspace!,
+ options.workflowName
+ );
}
};
+const compileHandler = async (options: CompileOptions, logger: Logger) => {
+ await runCompile(options, logger);
+
+ if (!options.watch) return;
+
+ const { targets, ignored } = collectWatchOptions(options, logger);
+ logger.info(`Watching for changes. Ctrl+C to stop.`);
+
+ const watcher = chokidar.watch(targets, {
+ ignoreInitial: true,
+ ignored,
+ });
+
+ watcher.on('change', async (changedPath: string) => {
+ logger.info(`${changedPath} changed, recompiling...`);
+ try {
+ await runCompile(options, logger);
+ } catch (e) {
+ logger.error('Compilation error:', e);
+ }
+ });
+
+ // Keep the process alive
+ await new Promise(() => {});
+};
+
export default compileHandler;
diff --git a/packages/cli/src/execute/serialize-output.ts b/packages/cli/src/execute/serialize-output.ts
index 175b3b6a6..611c5844c 100644
--- a/packages/cli/src/execute/serialize-output.ts
+++ b/packages/cli/src/execute/serialize-output.ts
@@ -4,7 +4,7 @@ import { Opts } from '../options';
import { dirname } from 'node:path';
const serializeOutput = async (
- options: Pick,
+ options: Pick,
result: any,
logger: Logger
) => {
@@ -22,7 +22,12 @@ const serializeOutput = async (
if (options.outputStdout) {
logger.success(`Result: `);
- logger.always(output);
+ if (options.log?.default === 'none') {
+ // special case because if we use logger.always the output won't be seen
+ console.log(output);
+ } else {
+ logger.always(output);
+ }
} else if (options.outputPath) {
await mkdir(dirname(options.outputPath), { recursive: true });
diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts
index 64087498b..557560574 100644
--- a/packages/cli/src/options.ts
+++ b/packages/cli/src/options.ts
@@ -66,8 +66,10 @@ export type Opts = {
statePath?: string;
stateStdin?: string;
timeout?: number; // ms
+ exportsOnly?: boolean;
trace?: boolean;
useAdaptorsMonorepo?: boolean;
+ watch?: boolean;
workflow: string;
workflowName?: string;
validate?: boolean;
@@ -632,6 +634,26 @@ export const validate: CLIOption = {
},
};
+export const exportsOnly: CLIOption = {
+ name: 'exports-only',
+ yargs: {
+ boolean: true,
+ description:
+ 'Strip adaptor operation calls, exporting only constants and functions',
+ default: false,
+ },
+};
+
+export const watchFlag: CLIOption = {
+ name: 'watch',
+ yargs: {
+ alias: ['w'],
+ boolean: true,
+ description: 'Watch source files and recompile on change',
+ default: false,
+ },
+};
+
export const workflow: CLIOption = {
name: 'workflow',
yargs: {
diff --git a/packages/cli/test/compile/compile.test.ts b/packages/cli/test/compile/compile.test.ts
index 6acdb9bf8..cab8e333b 100644
--- a/packages/cli/test/compile/compile.test.ts
+++ b/packages/cli/test/compile/compile.test.ts
@@ -1,5 +1,6 @@
import test from 'ava';
import mock from 'mock-fs';
+import fs from 'node:fs/promises';
import path from 'node:path';
import { createMockLogger } from '@openfn/logger';
@@ -7,6 +8,7 @@ import compile, {
stripVersionSpecifier,
loadTransformOptions,
resolveSpecifierPath,
+ compileProject,
} from '../../src/compile/compile';
import { CompileOptions } from '../../src/compile/command';
import { mockFs, resetMockFs } from '../util';
@@ -77,6 +79,156 @@ test.serial('compile from execution plan', async (t) => {
t.is((b as Job).expression, expected);
});
+test.serial('compile from project', async (t) => {
+ mockFs({
+ '/proj/openfn.yaml': `
+dirs:
+ workflows: /proj/workflows
+`,
+ '/proj/workflows/wf1/wf1.yaml': `
+id: wf1
+steps:
+ - id: step-a
+ expression: "x();"
+`,
+ });
+
+ const outPaths = await compileProject({} as CompileOptions, mockLog, '/proj');
+
+ t.is(outPaths.length, 1);
+ t.true(outPaths[0].endsWith('dist/wf1/step-a.mjs'));
+
+ const code = await fs.readFile(outPaths[0], 'utf-8');
+ t.true(code.includes('export default [x()]'));
+
+ mock.restore();
+});
+
+test.serial(
+ 'compile from project, skipping steps left empty by --exports-only',
+ async (t) => {
+ mockFs({
+ '/proj/openfn.yaml': `
+dirs:
+ workflows: /proj/workflows
+`,
+ '/proj/workflows/wf1/wf1.yaml': `
+id: wf1
+steps:
+ - id: step-a
+ expression: "export const helper = () => {}; fn();"
+ - id: step-b
+ expression: "fn();"
+`,
+ });
+
+ const outPaths = await compileProject(
+ { exportsOnly: true } as CompileOptions,
+ mockLog,
+ '/proj'
+ );
+
+ // Only step-a has exportable code — step-b should not be written
+ t.is(outPaths.length, 1);
+ t.true(outPaths[0].endsWith('step-a.mjs'));
+
+ mock.restore();
+ }
+);
+
+test.serial(
+ 'compile from project to the dirs.compiled folder in the workspace config',
+ async (t) => {
+ mockFs({
+ '/proj/openfn.yaml': `
+dirs:
+ workflows: /proj/workflows
+ compiled: build
+`,
+ '/proj/workflows/wf1/wf1.yaml': `
+id: wf1
+steps:
+ - id: step-a
+ expression: "x();"
+`,
+ });
+
+ const outPaths = await compileProject(
+ {} as CompileOptions,
+ mockLog,
+ '/proj'
+ );
+
+ t.is(outPaths.length, 1);
+ t.true(outPaths[0].startsWith('/proj/build/'));
+
+ mock.restore();
+ }
+);
+
+test.serial(
+ 'compile from project with --clean, removing the output dir first',
+ async (t) => {
+ mockFs({
+ '/proj/openfn.yaml': `
+dirs:
+ workflows: /proj/workflows
+`,
+ '/proj/workflows/wf1/wf1.yaml': `
+id: wf1
+steps:
+ - id: step-a
+ expression: "x();"
+`,
+ // A stale file from a previous compile
+ '/proj/dist/old-workflow/stale.mjs': 'export default [];',
+ });
+
+ // Without --clean, the stale file survives
+ await compileProject({} as CompileOptions, mockLog, '/proj');
+ await t.notThrowsAsync(() =>
+ fs.access('/proj/dist/old-workflow/stale.mjs')
+ );
+
+ // With --clean, it is removed
+ const outPaths = await compileProject(
+ { clean: true } as CompileOptions,
+ mockLog,
+ '/proj'
+ );
+ t.is(outPaths.length, 1);
+ await t.throwsAsync(() => fs.access('/proj/dist/old-workflow/stale.mjs'));
+
+ mock.restore();
+ }
+);
+
+test.serial('compile from project to outputPath', async (t) => {
+ mockFs({
+ '/proj/openfn.yaml': `
+dirs:
+ workflows: /proj/workflows
+`,
+ '/proj/workflows/wf1/wf1.yaml': `
+id: wf1
+steps:
+ - id: step-a
+ expression: "x();"
+`,
+ });
+
+ const outPaths = await compileProject(
+ { outputPath: '/out' } as CompileOptions,
+ mockLog,
+ '/proj'
+ );
+
+ t.is(outPaths.length, 1);
+ t.true(outPaths[0].startsWith('/out/'));
+
+ mock.restore();
+});
+
test.serial('throw an AbortError if a job is uncompilable', async (t) => {
const job = 'a b';
@@ -342,3 +494,12 @@ test.serial('loadTransformOptions: ignore some imports', async (t) => {
});
// TODO test exception if the module can't be found
+
+test.serial(
+ 'loadTransformOptions: --exports-only does not affect other options',
+ async (t) => {
+ const opts = { exportsOnly: true } as CompileOptions;
+ const result = await loadTransformOptions(opts, mockLog);
+ t.falsy(result['add-imports']);
+ }
+);
diff --git a/packages/cli/test/compile/handler.test.ts b/packages/cli/test/compile/handler.test.ts
new file mode 100644
index 000000000..24ec95d2e
--- /dev/null
+++ b/packages/cli/test/compile/handler.test.ts
@@ -0,0 +1,94 @@
+import test from 'ava';
+import fs from 'node:fs/promises';
+import { createMockLogger } from '@openfn/logger';
+
+import compileHandler from '../../src/compile/handler';
+import { CompileOptions } from '../../src/compile/command';
+import { mockFs, resetMockFs } from '../util';
+
+const logger = createMockLogger();
+
+test.afterEach(() => {
+ logger._reset();
+});
+
+test.after(resetMockFs);
+
+test.serial('compile an expression file to stdout', async (t) => {
+ mockFs({
+ '/job.js': 'x();',
+ });
+
+ const options = { expressionPath: '/job.js' } as CompileOptions;
+ await compileHandler(options, logger);
+
+ t.truthy(logger._find('success', /export default \[x\(\)\];/));
+});
+
+test.serial('compile an expression file to outputPath', async (t) => {
+ mockFs({
+ '/job.js': 'x();',
+ });
+
+ const options = {
+ expressionPath: '/job.js',
+ outputPath: '/out/job.js',
+ } as CompileOptions;
+ await compileHandler(options, logger);
+
+ const code = await fs.readFile('/out/job.js', 'utf-8');
+ t.is(code, 'export default [x()];');
+});
+
+test.serial('compile a workflow file to outputPath', async (t) => {
+ mockFs({
+ '/wf.json': JSON.stringify({
+ workflow: {
+ steps: [{ id: 'a', expression: 'x()' }],
+ },
+ }),
+ });
+
+ const options = {
+ planPath: '/wf.json',
+ outputPath: '/out/wf.json',
+ } as CompileOptions;
+ await compileHandler(options, logger);
+
+ const plan = JSON.parse(await fs.readFile('/out/wf.json', 'utf-8'));
+ t.is(plan.workflow.steps[0].expression, 'export default [x()];');
+});
+
+test.serial(
+ 'compile the checked-out project when no path is given',
+ async (t) => {
+ mockFs({
+ '/proj/openfn.yaml': `
+dirs:
+ workflows: /proj/workflows
+`,
+ '/proj/workflows/wf1/wf1.yaml': `
+id: wf1
+steps:
+ - id: step-a
+ expression: "x();"
+`,
+ });
+
+ const options = { workspace: '/proj' } as CompileOptions;
+ await compileHandler(options, logger);
+
+ const code = await fs.readFile('/proj/dist/wf1/step-a.mjs', 'utf-8');
+ t.true(code.includes('export default [x()]'));
+ }
+);
+
+test.serial('throw if the expression file does not exist', async (t) => {
+ mockFs({});
+
+ const options = { expressionPath: '/missing.js' } as CompileOptions;
+
+ await t.throwsAsync(() => compileHandler(options, logger), {
+ message: /failed to compile/i,
+ });
+});
diff --git a/packages/cli/test/compile/options.test.ts b/packages/cli/test/compile/options.test.ts
index a43b76b6e..e6a557a0c 100644
--- a/packages/cli/test/compile/options.test.ts
+++ b/packages/cli/test/compile/options.test.ts
@@ -15,7 +15,7 @@ test('correct default options', (t) => {
t.is(options.expandAdaptors, true);
t.is(options.expressionPath, 'job.js');
t.falsy(options.logJson); // TODO this is undefined right now
- t.is(options.outputStdout, true);
+ t.is(options.outputStdout, false);
t.is(options.path, 'job.js');
t.falsy(options.useAdaptorsMonorepo);
});
@@ -91,3 +91,40 @@ test('disable some imports', (t) => {
t.is(a, 'jam');
t.is(b, 'jar');
});
+
+test('clean is disabled by default', (t) => {
+ const options = parse('compile');
+ t.false(options.clean);
+});
+
+test('enable clean', (t) => {
+ const options = parse('compile --clean');
+ t.true(options.clean);
+});
+
+test('workspace defaults to cwd', (t) => {
+ const options = parse('compile');
+ t.is(options.workspace, process.cwd());
+});
+
+test('set a workspace path', (t) => {
+ const options = parse('compile --workspace /tmp/proj');
+ t.is(options.workspace, '/tmp/proj');
+});
+
+test('a path with a file extension maps to a file input', (t) => {
+ const options = parse('compile job.js');
+ t.is(options.expressionPath, 'job.js');
+ t.falsy(options.workflowName);
+
+ const wfOptions = parse('compile workflow.json');
+ t.is(wfOptions.planPath, 'workflow.json');
+ t.falsy(wfOptions.workflowName);
+});
+
+test('a path without a file extension maps to a workflow name', (t) => {
+ const options = parse('compile my-workflow');
+ t.is(options.workflowName, 'my-workflow');
+ t.falsy(options.expressionPath);
+ t.falsy(options.planPath);
+});
diff --git a/packages/compiler/CHANGELOG.md b/packages/compiler/CHANGELOG.md
index edc11af03..82d1fd546 100644
--- a/packages/compiler/CHANGELOG.md
+++ b/packages/compiler/CHANGELOG.md
@@ -1,5 +1,23 @@
# @openfn/compiler
+## 1.3.0
+
+### Minor Changes
+
+- ac44559: Improve `openfn compile` for unit-testing job code
+
+ - Add `--exports-only` to strip adaptor operation calls, keeping only exported declarations
+ - Compile whole projects (or a single workflow by name) to `dist/` as `.mjs` files
+ - Add `--clean` to remove the output folder before compiling
+ - Add `--workspace` support (as in `openfn execute` and `openfn project`)
+ - Support a `dirs.compiled` key in the workspace config (openfn.yaml) to set the output folder
+ - Watch mode now respects the configured workflows and output directories
+
+### Patch Changes
+
+- Updated dependencies [ac44559]
+ - @openfn/lexicon@2.4.0
+
## 1.2.5
### Patch Changes
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index 21d60dc03..e7e113751 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -1,6 +1,6 @@
{
"name": "@openfn/compiler",
- "version": "1.2.5",
+ "version": "1.3.0",
"description": "Compiler and language tooling for openfn jobs.",
"author": "Open Function Group ",
"license": "ISC",
diff --git a/packages/compiler/src/compile.ts b/packages/compiler/src/compile.ts
index 297682cbe..9899c08c3 100644
--- a/packages/compiler/src/compile.ts
+++ b/packages/compiler/src/compile.ts
@@ -2,7 +2,7 @@ import { print } from 'recast';
import createLogger, { Logger } from '@openfn/logger';
import parse from './parse';
-import transform, { TransformOptions } from './transform';
+import transform, { TransformOptions, Transformer } from './transform';
import { isPath, loadFile } from './util';
import type { SourceMapWithOperations } from '@openfn/lexicon';
@@ -23,7 +23,8 @@ export type Options = TransformOptions & {
export default function compile(
pathOrSource: string,
- options: Options = {}
+ options: Options = {},
+ transformers?: Transformer[]
): {
code: string;
map?: SourceMapWithOperations;
@@ -42,7 +43,7 @@ export default function compile(
const name = options.name ?? 'src';
const trace = options.trace;
const ast = parse(source, { logger, name, trace });
- const transformedAst = transform(ast, undefined, options);
+ const transformedAst = transform(ast, transformers, options);
const { code, map } = print(transformedAst, {
sourceMapName: `${name}.map.js`,
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index 401388960..00b94ed5a 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -2,6 +2,7 @@ import compile from './compile';
export { default as getExports } from './get-exports';
export * from './util';
+export * as transformers from './transforms/index';
export type { TransformOptions } from './transform';
export type { Options } from './compile';
export default compile;
diff --git a/packages/compiler/src/transform.ts b/packages/compiler/src/transform.ts
index 0e3245960..367b9fa53 100644
--- a/packages/compiler/src/transform.ts
+++ b/packages/compiler/src/transform.ts
@@ -15,6 +15,7 @@ import { heap } from './util';
export type TransformerName =
| 'add-imports'
| 'ensure-exports'
+ | 'exports-only'
| 'top-level-operations'
| 'test'
| 'lazy-state';
@@ -38,6 +39,7 @@ export type TransformOptions = {
['add-imports']?: AddImportsOptions | boolean;
['ensure-exports']?: boolean;
+ ['exports-only']?: boolean;
['top-level-operations']?: TopLevelOpsOptions | boolean;
['test']?: any;
['lazy-state']?: any;
diff --git a/packages/compiler/src/transforms/exports-only.ts b/packages/compiler/src/transforms/exports-only.ts
new file mode 100644
index 000000000..c07e81024
--- /dev/null
+++ b/packages/compiler/src/transforms/exports-only.ts
@@ -0,0 +1,63 @@
+/*
+ * Strips all non-exported top-level code (operations, export default, bare declarations).
+ */
+
+import { namedTypes as n } from 'ast-types';
+import type { NodePath } from 'ast-types/lib/node-path';
+import type { Transformer } from '../transform';
+
+// Names referenced by bare export lists, eg export { x, y }
+const findExportListNames = (body: any[]) => {
+ const names = new Set();
+ for (const node of body) {
+ if (
+ n.ExportNamedDeclaration.check(node) &&
+ !node.declaration &&
+ !node.source
+ ) {
+ for (const spec of node.specifiers ?? []) {
+ if (n.Identifier.check(spec.local)) {
+ names.add(spec.local.name);
+ }
+ }
+ }
+ }
+ return names;
+};
+
+const declaresName = (node: any, names: Set) => {
+ if (n.FunctionDeclaration.check(node) || n.ClassDeclaration.check(node)) {
+ return Boolean(node.id && names.has(node.id.name));
+ }
+ if (n.VariableDeclaration.check(node)) {
+ return node.declarations.some(
+ (d) =>
+ n.VariableDeclarator.check(d) &&
+ n.Identifier.check(d.id) &&
+ names.has(d.id.name)
+ );
+ }
+ return false;
+};
+
+function visitor(programPath: NodePath) {
+ const { body } = programPath.node;
+ const exportListNames = findExportListNames(body);
+
+ programPath.node.body = body.filter(
+ (node) =>
+ n.ImportDeclaration.check(node) ||
+ n.ExportNamedDeclaration.check(node) ||
+ n.ExportAllDeclaration.check(node) ||
+ declaresName(node, exportListNames)
+ ) as any;
+
+ return true; // abort further traversal
+}
+
+export default {
+ id: 'exports-only',
+ types: ['Program'],
+ order: 0,
+ visitor,
+} as unknown as Transformer;
diff --git a/packages/compiler/src/transforms/index.ts b/packages/compiler/src/transforms/index.ts
new file mode 100644
index 000000000..765e80e99
--- /dev/null
+++ b/packages/compiler/src/transforms/index.ts
@@ -0,0 +1,9 @@
+export { default as addImports } from './add-imports';
+export { default as ensureExports } from './ensure-exports';
+export { default as exportsOnly } from './exports-only';
+export { default as lazyState } from './lazy-state';
+export { default as promises } from './promises';
+export { default as topLevelOps } from './top-level-operations';
+
+export type { AddImportsOptions } from './add-imports';
+export type { TopLevelOpsOptions } from './top-level-operations';
diff --git a/packages/compiler/test/compile.test.ts b/packages/compiler/test/compile.test.ts
index 614e26344..529f3889f 100644
--- a/packages/compiler/test/compile.test.ts
+++ b/packages/compiler/test/compile.test.ts
@@ -2,6 +2,7 @@ import test from 'ava';
import fs from 'node:fs/promises';
import path from 'node:path';
import compile from '../src/compile';
+import exportsOnly from '../src/transforms/exports-only';
// Not doing deep testing on this because recast does the heavy lifting
// This is just to ensure the map is actually generated
@@ -278,3 +279,24 @@ test('respect ignore list when exports not provided', (t) => {
const { code: result } = compile(source, options);
t.is(result, expected);
});
+
+const exportsOnlyOpts = {
+ 'exports-only': true,
+ 'ensure-exports': false,
+ 'top-level-operations': false,
+} as const;
+
+test('only run transformers that are passed in', (t) => {
+ const source = [
+ 'export const formatDate = (d) => d.toISOString();',
+ 'get("/api");',
+ 'fn(state => ({ ...state, date: formatDate(state.data.date) }));',
+ ].join('\n');
+
+ const { code: result } = compile(source, exportsOnlyOpts, [exportsOnly]);
+
+ t.true(result.includes('export const formatDate'));
+ t.false(result.includes('get('));
+ t.false(result.includes('fn(state'));
+ t.false(result.includes('export default []'));
+});
diff --git a/packages/compiler/test/transforms/exports-only.test.ts b/packages/compiler/test/transforms/exports-only.test.ts
new file mode 100644
index 000000000..49302cddb
--- /dev/null
+++ b/packages/compiler/test/transforms/exports-only.test.ts
@@ -0,0 +1,214 @@
+import test from 'ava';
+import { print } from 'recast';
+import parse from '../../src/parse';
+import transform from '../../src/transform';
+import visitors from '../../src/transforms/exports-only';
+
+const enabled = { 'exports-only': true };
+const disabled = { 'exports-only': false };
+
+test('strips a non-exported value', (t) => {
+ const before = `const x = 42;
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, '');
+});
+
+test('is a no-op if exports-only is false', (t) => {
+ const before = `const x = 42;
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], disabled);
+ const after = print(transformed).code;
+
+ t.is(after, before);
+});
+
+test('strips operation calls', (t) => {
+ const before = `get();
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, '');
+});
+
+test('strips export default []', (t) => {
+ const before = `fn();
+export default [];`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, '');
+});
+
+test('keeps import declarations', (t) => {
+ const before = `import { get } from '@openfn/language-http';
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, `import { get } from '@openfn/language-http';`);
+});
+
+test('keeps named export declarations', (t) => {
+ const before = `export const helper = 42;
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, `export const helper = 42;`);
+});
+
+test('keeps exported function declarations', (t) => {
+ const before = `export function formatDate() {
+ return 1;
+}
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(
+ after,
+ `export function formatDate() {
+ return 1;
+}`
+ );
+});
+
+test('drops non-exported declarations that no export depends on', (t) => {
+ const before = `const unused = 42;
+export function greet() {
+ return 'hi';
+}
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(
+ after,
+ `export function greet() {
+ return 'hi';
+}`
+ );
+});
+
+test('keeps imports alongside named exports', (t) => {
+ const before = `import { dateFns } from '@openfn/language-dhis2';
+export const formatDate = 42;
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(
+ after,
+ `import { dateFns } from '@openfn/language-dhis2';
+export const formatDate = 42;`
+ );
+});
+
+test('keeps declarations referenced by an export list', (t) => {
+ const before = `const x = 1;
+export { x };
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(
+ after,
+ `const x = 1;
+export { x };`
+ );
+});
+
+test('keeps a function referenced by an aliased export list', (t) => {
+ const before = `function formatDate() {
+ return 1;
+}
+export { formatDate as format };
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(
+ after,
+ `function formatDate() {
+ return 1;
+}
+export { formatDate as format };`
+ );
+});
+
+test('keeps re-exports from another module', (t) => {
+ const before = `export { helper } from './helpers';
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, `export { helper } from './helpers';`);
+});
+
+test('keeps export * from another module', (t) => {
+ const before = `export * from './helpers';
+fn();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, `export * from './helpers';`);
+});
+
+test('handles a file with only operations (no exports)', (t) => {
+ const before = `fn();
+get();`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, '');
+});
+
+test('handles an empty file', (t) => {
+ const before = '';
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, '');
+});
+
+test('handles multiple exports without operations', (t) => {
+ const before = `export const a = 42;
+export const b = 42;
+export function c() {
+ return 1;
+}`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], enabled);
+ const after = print(transformed).code;
+
+ t.is(after, before);
+});
+
+test('does not remove export default when exports-only is disabled', (t) => {
+ const before = `fn();
+export default [];`;
+ const ast = parse(before);
+ const transformed = transform(ast, [visitors], disabled);
+ const after = print(transformed).code;
+
+ t.is(after, before);
+});
diff --git a/packages/engine-multi/CHANGELOG.md b/packages/engine-multi/CHANGELOG.md
index fd5fb6940..d23b49653 100644
--- a/packages/engine-multi/CHANGELOG.md
+++ b/packages/engine-multi/CHANGELOG.md
@@ -1,5 +1,13 @@
# engine-multi
+## 1.12.3
+
+### Patch Changes
+
+- Updated dependencies [ac44559]
+ - @openfn/compiler@1.3.0
+ - @openfn/lexicon@2.4.0
+
## 1.12.2
### Patch Changes
diff --git a/packages/engine-multi/package.json b/packages/engine-multi/package.json
index 54ea77cac..b50878cba 100644
--- a/packages/engine-multi/package.json
+++ b/packages/engine-multi/package.json
@@ -1,6 +1,6 @@
{
"name": "@openfn/engine-multi",
- "version": "1.12.2",
+ "version": "1.12.3",
"description": "Multi-process runtime engine",
"main": "dist/index.js",
"type": "module",
diff --git a/packages/lexicon/CHANGELOG.md b/packages/lexicon/CHANGELOG.md
index 85715c9ec..491b610ce 100644
--- a/packages/lexicon/CHANGELOG.md
+++ b/packages/lexicon/CHANGELOG.md
@@ -1,5 +1,18 @@
# lexicon
+## 2.4.0
+
+### Minor Changes
+
+- ac44559: Improve `openfn compile` for unit-testing job code
+
+ - Add `--exports-only` to strip adaptor operation calls, keeping only exported declarations
+ - Compile whole projects (or a single workflow by name) to `dist/` as `.mjs` files
+ - Add `--clean` to remove the output folder before compiling
+ - Add `--workspace` support (as in `openfn execute` and `openfn project`)
+ - Support a `dirs.compiled` key in the workspace config (openfn.yaml) to set the output folder
+ - Watch mode now respects the configured workflows and output directories
+
## 2.3.0
### Minor Changes
diff --git a/packages/lexicon/core.d.ts b/packages/lexicon/core.d.ts
index 1158ffb69..bdcfaaf68 100644
--- a/packages/lexicon/core.d.ts
+++ b/packages/lexicon/core.d.ts
@@ -147,6 +147,7 @@ export interface WorkspaceConfig {
dirs: {
workflows: string;
projects: string;
+ compiled?: string;
};
formats: {
openfn?: FileFormats;
diff --git a/packages/lexicon/package.json b/packages/lexicon/package.json
index 7ea4cc990..51a8e9fc6 100644
--- a/packages/lexicon/package.json
+++ b/packages/lexicon/package.json
@@ -1,6 +1,6 @@
{
"name": "@openfn/lexicon",
- "version": "2.3.0",
+ "version": "2.4.0",
"description": "Central repo of names and type definitions",
"author": "Open Function Group ",
"license": "ISC",
diff --git a/packages/lightning-mock/CHANGELOG.md b/packages/lightning-mock/CHANGELOG.md
index f865607cc..eec62d60b 100644
--- a/packages/lightning-mock/CHANGELOG.md
+++ b/packages/lightning-mock/CHANGELOG.md
@@ -1,5 +1,14 @@
# @openfn/lightning-mock
+## 2.4.24
+
+### Patch Changes
+
+- Updated dependencies [ac44559]
+ - @openfn/lexicon@2.4.0
+ - @openfn/project@0.18.0
+ - @openfn/engine-multi@1.12.3
+
## 2.4.23
### Patch Changes
diff --git a/packages/lightning-mock/package.json b/packages/lightning-mock/package.json
index 5451acce4..4bcbaf3cc 100644
--- a/packages/lightning-mock/package.json
+++ b/packages/lightning-mock/package.json
@@ -1,6 +1,6 @@
{
"name": "@openfn/lightning-mock",
- "version": "2.4.23",
+ "version": "2.4.24",
"private": true,
"description": "A mock Lightning server",
"main": "dist/index.js",
diff --git a/packages/project/CHANGELOG.md b/packages/project/CHANGELOG.md
index 09cadf75d..d8588ef1e 100644
--- a/packages/project/CHANGELOG.md
+++ b/packages/project/CHANGELOG.md
@@ -1,5 +1,23 @@
# @openfn/project
+## 0.18.0
+
+### Minor Changes
+
+- ac44559: Improve `openfn compile` for unit-testing job code
+
+ - Add `--exports-only` to strip adaptor operation calls, keeping only exported declarations
+ - Compile whole projects (or a single workflow by name) to `dist/` as `.mjs` files
+ - Add `--clean` to remove the output folder before compiling
+ - Add `--workspace` support (as in `openfn execute` and `openfn project`)
+ - Support a `dirs.compiled` key in the workspace config (openfn.yaml) to set the output folder
+ - Watch mode now respects the configured workflows and output directories
+
+### Patch Changes
+
+- Updated dependencies [ac44559]
+ - @openfn/lexicon@2.4.0
+
## 0.17.2
### Patch Changes
diff --git a/packages/project/package.json b/packages/project/package.json
index 1590c10a2..cb2564f8a 100644
--- a/packages/project/package.json
+++ b/packages/project/package.json
@@ -1,6 +1,6 @@
{
"name": "@openfn/project",
- "version": "0.17.2",
+ "version": "0.18.0",
"description": "Read, serialize, replicate and sync OpenFn projects",
"scripts": {
"test": "pnpm ava",
diff --git a/packages/project/src/util/config.ts b/packages/project/src/util/config.ts
index 17bd59ecc..60ed24ec0 100644
--- a/packages/project/src/util/config.ts
+++ b/packages/project/src/util/config.ts
@@ -13,6 +13,7 @@ export const buildConfig = (config: Partial = {}) => ({
dirs: {
projects: config.dirs?.projects ?? '.projects',
workflows: config.dirs?.workflows ?? 'workflows',
+ ...(config.dirs?.compiled && { compiled: config.dirs.compiled }),
},
formats: {
openfn: config.formats?.openfn ?? 'yaml',
diff --git a/packages/ws-worker/CHANGELOG.md b/packages/ws-worker/CHANGELOG.md
index a3de076a9..4bed703a9 100644
--- a/packages/ws-worker/CHANGELOG.md
+++ b/packages/ws-worker/CHANGELOG.md
@@ -1,5 +1,13 @@
# ws-worker
+## 1.27.3
+
+### Patch Changes
+
+- Updated dependencies [ac44559]
+ - @openfn/lexicon@2.4.0
+ - @openfn/engine-multi@1.12.3
+
## 1.27.2
### Patch Changes
diff --git a/packages/ws-worker/package.json b/packages/ws-worker/package.json
index 3a00f4ea9..84b3198aa 100644
--- a/packages/ws-worker/package.json
+++ b/packages/ws-worker/package.json
@@ -1,6 +1,6 @@
{
"name": "@openfn/ws-worker",
- "version": "1.27.2",
+ "version": "1.27.3",
"description": "A Websocket Worker to connect Lightning to a Runtime Engine",
"main": "dist/index.js",
"type": "module",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 64b8abe06..48b884fb8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -213,6 +213,9 @@ importers:
chalk:
specifier: ^5.6.2
version: 5.6.2
+ chokidar:
+ specifier: ^3.6.0
+ version: 3.6.0
dotenv:
specifier: ^17.3.1
version: 17.3.1