From ff1b1b6f5b86d0dfc54a2521cae3c7a7a56baa54 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 1 Jun 2026 17:46:49 +0700 Subject: [PATCH 1/4] feat(ws-worker): support multi-root @local adaptor resolution (#1397) * feat(ws-worker): support multi-root @local adaptor resolution OPENFN_ADAPTORS_REPO (and the --monorepo-dir / -m flag) now accept a colon-separated list of monorepo roots. When a job pins an adaptor to @local, the worker walks the configured roots in order and resolves to the first root whose `packages//package.json` exists. This matches Lightning's AdaptorRegistry precedence so the registry view and the worker's execution path agree on which root supplies a given adaptor. Single-path values keep behaving exactly as before. When no root contains the adaptor the worker still surfaces a candidate path under the first root, so the runtime emits a clean "missing adaptor" error rather than crashing on a malformed colon-joined string. This unblocks the multi-root flow on the Lightning side, where the AdaptorRegistry already accepts the colon-separated form but the worker was rejecting it with ENOENT on @local execution. * fix(ws-worker): use comma to separate multi-root adaptor paths Colon collides with Windows drive letters (c:/repo); comma matches Lightning's parsing of OPENFN_ADAPTORS_REPO. Single-path callers are unchanged. --- .changeset/multi-root-local-adaptors.md | 5 + packages/ws-worker/src/util/cli.ts | 2 +- .../src/util/convert-lightning-plan.ts | 48 +++++-- .../test/util/convert-lightning-plan.test.ts | 120 ++++++++++++++++++ 4 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 .changeset/multi-root-local-adaptors.md diff --git a/.changeset/multi-root-local-adaptors.md b/.changeset/multi-root-local-adaptors.md new file mode 100644 index 000000000..7e82da71d --- /dev/null +++ b/.changeset/multi-root-local-adaptors.md @@ -0,0 +1,5 @@ +--- +'@openfn/ws-worker': minor +--- + +Support comma-separated `OPENFN_ADAPTORS_REPO` so a private adaptor monorepo can be loaded alongside the canonical OpenFn adaptors monorepo. When a job pins an adaptor to `@local`, the worker now walks the configured roots in order and resolves to the first root that contains `packages//package.json`. Single-path values continue to work unchanged. Earlier paths win on collision, mirroring Lightning's `AdaptorRegistry` precedence rules so the registry view and the worker's execution path stay consistent. diff --git a/packages/ws-worker/src/util/cli.ts b/packages/ws-worker/src/util/cli.ts index f8d27a126..ebae7ccb5 100644 --- a/packages/ws-worker/src/util/cli.ts +++ b/packages/ws-worker/src/util/cli.ts @@ -132,7 +132,7 @@ export default function parseArgs(argv: string[]): Args { .option('monorepo-dir', { alias: 'm', description: - 'Path to the adaptors monorepo, from where @local adaptors will be loaded. Env: OPENFN_ADAPTORS_REPO', + 'Path to the adaptors monorepo, from where @local adaptors will be loaded. Accepts a comma-separated list to merge multiple monorepos; the first path containing a given adaptor wins. Env: OPENFN_ADAPTORS_REPO', }) .option('secret', { alias: 's', diff --git a/packages/ws-worker/src/util/convert-lightning-plan.ts b/packages/ws-worker/src/util/convert-lightning-plan.ts index 1f8f575ec..4307e7dbe 100644 --- a/packages/ws-worker/src/util/convert-lightning-plan.ts +++ b/packages/ws-worker/src/util/convert-lightning-plan.ts @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import fs from 'node:fs'; import path from 'node:path'; import type { State, WorkflowOptions } from '@openfn/lexicon'; import type { @@ -52,18 +53,49 @@ export default ( ): { plan: ExecutionPlan; options: WorkerRunOptions; input: Lazy } => { const { collectionsVersion, monorepoPath } = options; + // monorepoPath is a comma-separated list of monorepo roots, mirroring how + // Lightning's AdaptorRegistry parses OPENFN_ADAPTORS_REPO. A single path + // (the common case) just becomes a one-element list. Order is precedence: + // when a `packages/` directory exists in more than one root, the + // earlier entry wins, so a private adaptor repo can be listed before the + // canonical OpenFn monorepo to override individual adaptors locally. Comma + // (rather than ':') keeps Windows drive-letter paths like `c:/repo` usable. + const monorepoRoots = (monorepoPath ?? '') + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + const resolveLocalAdaptorPath = (shortName: string): string | undefined => { + if (monorepoRoots.length === 0) return undefined; + + for (const root of monorepoRoots) { + const candidate = path.resolve(root, 'packages', shortName); + if (fs.existsSync(path.join(candidate, 'package.json'))) { + return candidate; + } + } + // Fall back to the first root's resolved candidate path. The directory + // does not exist, but this surfaces a recognisable "missing local + // adaptor" path to the runtime instead of an unresolved joined string. + // It also preserves the single-path behaviour from before multi-root + // support was added (the path was returned without an existence check). + return path.resolve(monorepoRoots[0], 'packages', shortName); + }; + const appendLocalVersions = (job: Job) => { - if (monorepoPath && job.adaptors!) { + if (monorepoRoots.length && job.adaptors!) { for (const adaptor of job.adaptors) { const { name, version } = getNameAndVersion(adaptor); - if (monorepoPath && version === 'local') { + if (version === 'local') { const shortName = name.replace('@openfn/language-', ''); - const localPath = path.resolve(monorepoPath, 'packages', shortName); - job.linker ??= {}; - job.linker[name] = { - path: localPath, - version: 'local', - }; + const localPath = resolveLocalAdaptorPath(shortName); + if (localPath) { + job.linker ??= {}; + job.linker[name] = { + path: localPath, + version: 'local', + }; + } } } } diff --git a/packages/ws-worker/test/util/convert-lightning-plan.test.ts b/packages/ws-worker/test/util/convert-lightning-plan.test.ts index 20622b534..12a11a0e4 100644 --- a/packages/ws-worker/test/util/convert-lightning-plan.test.ts +++ b/packages/ws-worker/test/util/convert-lightning-plan.test.ts @@ -1,4 +1,7 @@ import test from 'ava'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import type { LightningPlan, LightningJob, @@ -7,6 +10,17 @@ import type { import convertPlan from '../../src/util/convert-lightning-plan'; import { Job } from '@openfn/runtime'; +// Builds a temporary monorepo root with package.json files in each named adaptor. +const makeMonorepo = (adaptors: string[]) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multi-root-')); + for (const adaptor of adaptors) { + const pkgDir = path.join(root, 'packages', adaptor); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, 'package.json'), '{}'); + } + return root; +}; + // Creates a lightning node (job or trigger) const createNode = (props = {}) => ({ @@ -585,6 +599,112 @@ test('Use local paths', (t) => { }); }); +test('Use local paths: resolves @local against a single existing root', (t) => { + const root = makeMonorepo(['common']); + + const run: Partial = { + id: 'w', + jobs: [createNode({ id: 'a', adaptor: 'common@local' })], + triggers: [{ id: 't', type: 'cron' }], + edges: [createEdge('t', 'a')], + }; + + const { plan } = convertPlan(run as LightningPlan, { monorepoPath: root }); + const [, a] = plan.workflow.steps as any[]; + + t.deepEqual(a.linker.common, { + path: path.resolve(root, 'packages', 'common'), + version: 'local', + }); +}); + +test('Use local paths: walks comma-separated roots in order, first match wins', (t) => { + const privateRoot = makeMonorepo(['publicschema']); + const canonicalRoot = makeMonorepo(['common', 'publicschema']); + + const run: Partial = { + id: 'w', + jobs: [ + createNode({ id: 'a', adaptor: 'common@local' }), + createNode({ id: 'b', adaptor: 'publicschema@local' }), + ], + triggers: [{ id: 't', type: 'cron' }], + edges: [createEdge('t', 'a'), createEdge('a', 'b')], + }; + + const { plan } = convertPlan(run as LightningPlan, { + monorepoPath: `${privateRoot},${canonicalRoot}`, + }); + const [, a, b] = plan.workflow.steps as any[]; + + // common only exists in the canonical root, so it falls through. + t.is(a.linker.common.path, path.resolve(canonicalRoot, 'packages', 'common')); + // publicschema exists in both; the private (earlier) root wins. + t.is( + b.linker.publicschema.path, + path.resolve(privateRoot, 'packages', 'publicschema') + ); +}); + +test('Use local paths: ignores roots that do not contain the adaptor', (t) => { + const emptyRoot = makeMonorepo([]); + const realRoot = makeMonorepo(['http']); + + const run: Partial = { + id: 'w', + jobs: [createNode({ id: 'a', adaptor: 'http@local' })], + triggers: [{ id: 't', type: 'cron' }], + edges: [createEdge('t', 'a')], + }; + + const { plan } = convertPlan(run as LightningPlan, { + monorepoPath: `${emptyRoot},${realRoot}`, + }); + const [, a] = plan.workflow.steps as any[]; + + t.is(a.linker.http.path, path.resolve(realRoot, 'packages', 'http')); +}); + +test('Use local paths: trims whitespace and drops empty segments', (t) => { + const root = makeMonorepo(['common']); + + const run: Partial = { + id: 'w', + jobs: [createNode({ id: 'a', adaptor: 'common@local' })], + triggers: [{ id: 't', type: 'cron' }], + edges: [createEdge('t', 'a')], + }; + + const { plan } = convertPlan(run as LightningPlan, { + monorepoPath: ` , ${root} , `, + }); + const [, a] = plan.workflow.steps as any[]; + + t.is(a.linker.common.path, path.resolve(root, 'packages', 'common')); +}); + +test('Use local paths: falls back to the first root when no root has the adaptor', (t) => { + const rootA = makeMonorepo([]); + const rootB = makeMonorepo([]); + + const run: Partial = { + id: 'w', + jobs: [createNode({ id: 'a', adaptor: 'mystery@local' })], + triggers: [{ id: 't', type: 'cron' }], + edges: [createEdge('t', 'a')], + }; + + const { plan } = convertPlan(run as LightningPlan, { + monorepoPath: `${rootA},${rootB}`, + }); + const [, a] = plan.workflow.steps as any[]; + + // The candidate path under the first root is surfaced even though the + // adaptor is missing, so the runtime emits a clean "missing adaptor" + // error instead of crashing on a malformed joined path. + t.is(a.linker.mystery.path, path.resolve(rootA, 'packages', 'mystery')); +}); + test('pass globals from lightning run to plan', (t) => { const GLOBALS_CONTENT = "export const prefixer = (v) => 'prefix-' + v"; const run: Partial = { From f61636df32dbcdc2586f7ed1d5ad29a94378b7e0 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Mon, 1 Jun 2026 11:52:15 +0100 Subject: [PATCH 2/4] simplify changeset --- .changeset/multi-root-local-adaptors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/multi-root-local-adaptors.md b/.changeset/multi-root-local-adaptors.md index 7e82da71d..9973fb7f6 100644 --- a/.changeset/multi-root-local-adaptors.md +++ b/.changeset/multi-root-local-adaptors.md @@ -2,4 +2,4 @@ '@openfn/ws-worker': minor --- -Support comma-separated `OPENFN_ADAPTORS_REPO` so a private adaptor monorepo can be loaded alongside the canonical OpenFn adaptors monorepo. When a job pins an adaptor to `@local`, the worker now walks the configured roots in order and resolves to the first root that contains `packages//package.json`. Single-path values continue to work unchanged. Earlier paths win on collision, mirroring Lightning's `AdaptorRegistry` precedence rules so the registry view and the worker's execution path stay consistent. +`OPENFN_ADAPTORS_REPO` now supports multiple comma-separated paths. From dc4a812ea8ca4cb53c0638a067b4dcecc00351ec Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Mon, 1 Jun 2026 12:28:37 +0100 Subject: [PATCH 3/4] update cli --- .changeset/multi-root-local-adaptors.md | 1 + packages/cli/src/commands.ts | 2 +- packages/cli/src/options.ts | 13 +- .../cli/src/util/map-adaptors-to-monorepo.ts | 44 ++++-- .../ensure/useAdaptorsMonorepo.test.ts | 17 ++- packages/cli/test/util/load-plan.test.ts | 3 +- .../util/map-adaptors-to-monorepo.test.ts | 133 +++++++++++++++--- packages/cli/test/util/print-versions.test.ts | 4 +- 8 files changed, 173 insertions(+), 44 deletions(-) diff --git a/.changeset/multi-root-local-adaptors.md b/.changeset/multi-root-local-adaptors.md index 9973fb7f6..811a47032 100644 --- a/.changeset/multi-root-local-adaptors.md +++ b/.changeset/multi-root-local-adaptors.md @@ -1,5 +1,6 @@ --- '@openfn/ws-worker': minor +'@openfn/cli': minor --- `OPENFN_ADAPTORS_REPO` now supports multiple comma-separated paths. diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index c3a22e45f..620c18601 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -97,7 +97,7 @@ const parse = async (options: Opts, log?: Logger) => { const { monorepoPath } = options; if (monorepoPath) { // TODO how does this occur? - if (monorepoPath === 'ERR') { + if (monorepoPath[0] === 'ERR') { logger.error( 'ERROR: --use-adaptors-monorepo was passed, but OPENFN_ADAPTORS_REPO env var is undefined' ); diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index a97df63b1..64087498b 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -46,7 +46,7 @@ export type Opts = { keepUnsupported?: boolean; log?: Record; logJson?: boolean; - monorepoPath?: string; + monorepoPath?: string[]; only?: string; // only run this workflow node operation?: string; outputPath?: string; @@ -587,7 +587,16 @@ export const useAdaptorsMonorepo: CLIOption = { }, ensure: (opts) => { if (opts.useAdaptorsMonorepo) { - opts.monorepoPath = process.env.OPENFN_ADAPTORS_REPO || 'ERR'; + const repo = process.env.OPENFN_ADAPTORS_REPO; + // OPENFN_ADAPTORS_REPO is a comma-separated list of monorepo roots + // (a single path is just a one-element list) + opts.monorepoPath = repo + ? repo + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0) + .map((p) => nodePath.resolve(p)) + : ['ERR']; } }, }; diff --git a/packages/cli/src/util/map-adaptors-to-monorepo.ts b/packages/cli/src/util/map-adaptors-to-monorepo.ts index 833f36069..dcc586242 100644 --- a/packages/cli/src/util/map-adaptors-to-monorepo.ts +++ b/packages/cli/src/util/map-adaptors-to-monorepo.ts @@ -1,6 +1,5 @@ -import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import path from 'node:path'; -import assert from 'node:assert'; import { Logger } from '@openfn/logger'; import { getNameAndVersion, @@ -10,19 +9,21 @@ import { import type { Opts } from '../options'; -export const validateMonoRepo = async (repoPath: string, log: Logger) => { - try { - const raw = await readFile(`${repoPath}/package.json`, 'utf8'); - const pkg = JSON.parse(raw); - assert(pkg.name === 'adaptors'); - } catch (e) { - log.error(`ERROR: Adaptors Monorepo not found at ${repoPath}`); - process.exit(9); +export const validateMonoRepo = async (repoPaths: string[], log: Logger) => { + for (const repoPath of repoPaths) { + if (!existsSync(path.resolve(repoPath, 'packages'))) { + log.error(`ERROR: Adaptors Monorepo not found at ${repoPath}`); + process.exit(9); + } } }; // Convert an adaptor name into a path to the adaptor in the monorepo -export const updatePath = (adaptor: string, repoPath: string, log: Logger) => { +export const updatePath = ( + adaptor: string, + repoPaths: string[], + log: Logger +) => { if (adaptor.match('=')) { // Should do nothing if a path is already provided return adaptor; @@ -36,7 +37,22 @@ export const updatePath = (adaptor: string, repoPath: string, log: Logger) => { ); } const shortName = name.replace('@openfn/language-', ''); - const abspath = path.resolve(repoPath, 'packages', shortName); + + // Find the first root in the monorepo list that contains the adaptor + // (order is precedence, so an earlier root overrides a later one) + const abspath = repoPaths + .map((repoPath) => path.join(repoPath, 'packages', shortName)) + .find((candidate) => existsSync(candidate)); + + if (!abspath) { + if (repoPaths.length > 1) { + throw new Error( + `Adaptor ${name} not found in any provided adaptors monorepo` + ); + } else { + throw new Error(`Adaptor ${name} not found in the adaptors monorepo`); + } + } log.info(`Mapped adaptor ${name} to monorepo: ${abspath}`); return `${name}=${abspath}`; @@ -48,11 +64,11 @@ export type MapAdaptorsToMonorepoOptions = Pick< >; const mapAdaptorsToMonorepo = ( - monorepoPath: string = '', + monorepoPath: string[] = [], input: string[] | ExecutionPlan = [], log: Logger ): string[] | ExecutionPlan => { - if (monorepoPath) { + if (monorepoPath.length) { if (Array.isArray(input)) { const adaptors = input as string[]; return adaptors.map((a) => updatePath(a, monorepoPath, log)); diff --git a/packages/cli/test/options/ensure/useAdaptorsMonorepo.test.ts b/packages/cli/test/options/ensure/useAdaptorsMonorepo.test.ts index c25522c60..555c0c711 100644 --- a/packages/cli/test/options/ensure/useAdaptorsMonorepo.test.ts +++ b/packages/cli/test/options/ensure/useAdaptorsMonorepo.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import test from 'ava'; import { useAdaptorsMonorepo, Opts } from '../../../src/options'; @@ -41,7 +42,19 @@ test('monorepoPath is set with a value from OPENFN_ADAPTORS_REPO', (t) => { useAdaptorsMonorepo.ensure!(opts); - t.is(opts.monorepoPath, 'a/b/c'); + t.deepEqual(opts.monorepoPath, [path.resolve('a/b/c')]); + delete process.env.OPENFN_ADAPTORS_REPO; +}); + +test('monorepoPath is set with multiple comma-separated paths', (t) => { + process.env.OPENFN_ADAPTORS_REPO = 'a/b/c, d/e/f'; + const opts = { + useAdaptorsMonorepo: true, + } as Opts; + + useAdaptorsMonorepo.ensure!(opts); + + t.deepEqual(opts.monorepoPath, [path.resolve('a/b/c'), path.resolve('d/e/f')]); delete process.env.OPENFN_ADAPTORS_REPO; }); @@ -54,5 +67,5 @@ test('monorepoPath is set to an error value if OPENFN_ADAPTORS_REPO is not set', useAdaptorsMonorepo.ensure!(opts); - t.is(opts.monorepoPath, 'ERR'); + t.deepEqual(opts.monorepoPath, ['ERR']); }); diff --git a/packages/cli/test/util/load-plan.test.ts b/packages/cli/test/util/load-plan.test.ts index 68fbb8730..6597e07a2 100644 --- a/packages/cli/test/util/load-plan.test.ts +++ b/packages/cli/test/util/load-plan.test.ts @@ -331,7 +331,7 @@ test.serial('xplan: map to monorepo', async (t) => { workflowPath: 'test/wf.json', expandAdaptors: true, plan: {}, - monorepoPath: '/repo/', + monorepoPath: ['/repo/'], } as Partial; const plan = createPlan([ @@ -344,6 +344,7 @@ test.serial('xplan: map to monorepo', async (t) => { mock({ 'test/wf.json': JSON.stringify(plan), + '/repo/packages/common': {}, }); const result = await loadPlan(opts as Opts, logger); diff --git a/packages/cli/test/util/map-adaptors-to-monorepo.test.ts b/packages/cli/test/util/map-adaptors-to-monorepo.test.ts index 886bd942d..83218a7a6 100644 --- a/packages/cli/test/util/map-adaptors-to-monorepo.test.ts +++ b/packages/cli/test/util/map-adaptors-to-monorepo.test.ts @@ -9,31 +9,46 @@ import mapAdaptorsToMonorepo, { } from '../../src/util/map-adaptors-to-monorepo'; import { ExecutionPlan } from '@openfn/runtime'; -const REPO_PATH = 'a/b/c'; -const ABS_REPO_PATH = path.resolve(REPO_PATH); +// Paths are resolved to absolute in the option's ensure block, so the util +// always receives absolute roots +const REPO_PATH = path.resolve('a/b/c'); +const REPO_PATH_2 = path.resolve('d/e/f'); const logger = createMockLogger(); test.afterEach(() => { logger._reset(); + mock.restore(); }); -test('updatePath: common', (t) => { - const result = updatePath('common', REPO_PATH, logger); +test.serial('updatePath: common', (t) => { + mock({ + [`${REPO_PATH}/packages/common`]: {}, + }); + + const result = updatePath('common', [REPO_PATH], logger); - t.is(result, `common=${ABS_REPO_PATH}/packages/common`); + t.is(result, `common=${REPO_PATH}/packages/common`); }); -test('updatePath: @openfn/language-common', (t) => { - const result = updatePath('@openfn/language-common', REPO_PATH, logger); +test.serial('updatePath: @openfn/language-common', (t) => { + mock({ + [`${REPO_PATH}/packages/common`]: {}, + }); + + const result = updatePath('@openfn/language-common', [REPO_PATH], logger); - t.is(result, `@openfn/language-common=${ABS_REPO_PATH}/packages/common`); + t.is(result, `@openfn/language-common=${REPO_PATH}/packages/common`); }); -test('updatePath: common@1.2.3 (with warning)', (t) => { - const result = updatePath('common@1.2.3', REPO_PATH, logger); +test.serial('updatePath: common@1.2.3 (with warning)', (t) => { + mock({ + [`${REPO_PATH}/packages/common`]: {}, + }); - t.is(result, `common=${ABS_REPO_PATH}/packages/common`); + const result = updatePath('common@1.2.3', [REPO_PATH], logger); + + t.is(result, `common=${REPO_PATH}/packages/common`); const { level, message } = logger._parse(logger._last); t.is(level, 'warn'); @@ -41,18 +56,61 @@ test('updatePath: common@1.2.3 (with warning)', (t) => { }); test('updatePath: common=x/y/z', (t) => { - const result = updatePath('common=x/y/z', REPO_PATH, logger); + const result = updatePath('common=x/y/z', [REPO_PATH], logger); t.is(result, `common=x/y/z`); }); +test.serial('updatePath: prefer the root which has the adaptor', (t) => { + mock({ + [`${REPO_PATH_2}/packages/common`]: {}, + }); + + // common only exists in the second root, so that path should be used + const result = updatePath('common', [REPO_PATH, REPO_PATH_2], logger); + + t.is(result, `common=${REPO_PATH_2}/packages/common`); +}); + +test.serial('updatePath: earlier root wins when both have the adaptor', (t) => { + mock({ + [`${REPO_PATH}/packages/common`]: {}, + [`${REPO_PATH_2}/packages/common`]: {}, + }); + + const result = updatePath('common', [REPO_PATH, REPO_PATH_2], logger); + + t.is(result, `common=${REPO_PATH}/packages/common`); +}); + +test.serial('updatePath: throw if not found in the single root', (t) => { + mock({ + [`${REPO_PATH}/packages`]: {}, + }); + + t.throws(() => updatePath('common', [REPO_PATH], logger), { + message: /not found in the adaptors monorepo/, + }); +}); + +test.serial('updatePath: throw if not found in any root', (t) => { + mock({ + [`${REPO_PATH}/packages`]: {}, + [`${REPO_PATH_2}/packages`]: {}, + }); + + t.throws(() => updatePath('common', [REPO_PATH, REPO_PATH_2], logger), { + message: /not found in any provided adaptors monorepo/, + }); +}); + // TODO can't test this in ava, have to use an integration test test.skip('validate monorepo: log and exit early if repo not found', async (t) => { mock({ a: {}, }); - await t.throwsAsync(async () => validateMonoRepo(REPO_PATH, logger), { + await t.throwsAsync(async () => validateMonoRepo([REPO_PATH], logger), { message: 'Monorepo not found', }); const { level, message } = logger._parse(logger._last); @@ -60,26 +118,57 @@ test.skip('validate monorepo: log and exit early if repo not found', async (t) = t.is(message, `ERROR: Monorepo not found at ${REPO_PATH}`); }); -test('validate monorepo: all OK', async (t) => { +test.serial('validate monorepo: all OK', async (t) => { mock({ - [`${REPO_PATH}/package.json`]: '{ "name": "adaptors" }', + [`${REPO_PATH}/packages`]: {}, }); - await t.notThrowsAsync(async () => validateMonoRepo(REPO_PATH, logger)); + await t.notThrowsAsync(async () => validateMonoRepo([REPO_PATH], logger)); +}); + +test.serial('validate monorepo: all OK with multiple paths', async (t) => { + mock({ + [`${REPO_PATH}/packages`]: {}, + [`${REPO_PATH_2}/packages`]: {}, + }); + + await t.notThrowsAsync(async () => + validateMonoRepo([REPO_PATH, REPO_PATH_2], logger) + ); }); test.serial('mapAdaptorsToMonorepo: map adaptors', async (t) => { mock({ - [`${REPO_PATH}/package.json`]: '{ "name": "adaptors" }', + [`${REPO_PATH}/packages/common`]: {}, }); - const result = await mapAdaptorsToMonorepo(REPO_PATH, ['common'], logger); - t.deepEqual(result, [`common=${ABS_REPO_PATH}/packages/common`]); + const result = await mapAdaptorsToMonorepo([REPO_PATH], ['common'], logger); + t.deepEqual(result, [`common=${REPO_PATH}/packages/common`]); }); +test.serial( + 'mapAdaptorsToMonorepo: map adaptors across multiple roots', + async (t) => { + mock({ + [`${REPO_PATH}/packages/http`]: {}, + [`${REPO_PATH_2}/packages/common`]: {}, + }); + + const result = await mapAdaptorsToMonorepo( + [REPO_PATH, REPO_PATH_2], + ['http', 'common'], + logger + ); + t.deepEqual(result, [ + `http=${REPO_PATH}/packages/http`, + `common=${REPO_PATH_2}/packages/common`, + ]); + } +); + test.serial('mapAdaptorsToMonorepo: map workflow', async (t) => { mock({ - [`${REPO_PATH}/package.json`]: '{ "name": "adaptors" }', + [`${REPO_PATH}/packages/common`]: {}, }); const plan: ExecutionPlan = { @@ -94,12 +183,12 @@ test.serial('mapAdaptorsToMonorepo: map workflow', async (t) => { options: {}, }; - await mapAdaptorsToMonorepo(REPO_PATH, plan, logger); + await mapAdaptorsToMonorepo([REPO_PATH], plan, logger); t.deepEqual(plan.workflow, { steps: [ { expression: '.', - adaptors: [`common=${ABS_REPO_PATH}/packages/common`], + adaptors: [`common=${REPO_PATH}/packages/common`], }, ], }); diff --git a/packages/cli/test/util/print-versions.test.ts b/packages/cli/test/util/print-versions.test.ts index 05c387653..0ad35dd55 100644 --- a/packages/cli/test/util/print-versions.test.ts +++ b/packages/cli/test/util/print-versions.test.ts @@ -93,7 +93,7 @@ test('print version of adaptor with monorepo', async (t) => { const logger = createMockLogger('', { level: 'info' }); await printVersions(logger, { adaptors: ['@openfn/language-http@1.0.0'], - monorepoPath: '.', + monorepoPath: ['.'], }); const last = logger._parse(logger._last); @@ -128,7 +128,7 @@ test('print version of adaptor with path even if monorepo is set', async (t) => const logger = createMockLogger('', { level: 'info' }); await printVersions(logger, { adaptors: ['@openfn/language-http=/repo/http'], - monorepoPath: '.', + monorepoPath: ['.'], }); const last = logger._parse(logger._last); From 6ace99c9ea7fbccffd0bc12dcfbd79f7d156a936 Mon Sep 17 00:00:00 2001 From: Joe Clark Date: Tue, 2 Jun 2026 12:15:17 +0100 Subject: [PATCH 4/4] versions --- .changeset/multi-root-local-adaptors.md | 6 ------ packages/cli/CHANGELOG.md | 6 ++++++ packages/cli/package.json | 2 +- packages/ws-worker/CHANGELOG.md | 6 ++++++ packages/ws-worker/package.json | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) delete mode 100644 .changeset/multi-root-local-adaptors.md diff --git a/.changeset/multi-root-local-adaptors.md b/.changeset/multi-root-local-adaptors.md deleted file mode 100644 index 811a47032..000000000 --- a/.changeset/multi-root-local-adaptors.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@openfn/ws-worker': minor -'@openfn/cli': minor ---- - -`OPENFN_ADAPTORS_REPO` now supports multiple comma-separated paths. diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 1308fbb05..28c7a8057 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # @openfn/cli +## 1.37.0 + +### Minor Changes + +- ff1b1b6: `OPENFN_ADAPTORS_REPO` now supports multiple comma-separated paths. + ## 1.36.3 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 78dca3c84..fc8855c9b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/cli", - "version": "1.36.3", + "version": "1.37.0", "description": "CLI devtools for the OpenFn toolchain", "engines": { "node": ">=18", diff --git a/packages/ws-worker/CHANGELOG.md b/packages/ws-worker/CHANGELOG.md index 05edb9068..208bff465 100644 --- a/packages/ws-worker/CHANGELOG.md +++ b/packages/ws-worker/CHANGELOG.md @@ -1,5 +1,11 @@ # ws-worker +## 1.26.0 + +### Minor Changes + +- ff1b1b6: `OPENFN_ADAPTORS_REPO` now supports multiple comma-separated paths. + ## 1.25.1 ### Patch Changes diff --git a/packages/ws-worker/package.json b/packages/ws-worker/package.json index 25e8aa448..ed79eba73 100644 --- a/packages/ws-worker/package.json +++ b/packages/ws-worker/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/ws-worker", - "version": "1.25.1", + "version": "1.26.0", "description": "A Websocket Worker to connect Lightning to a Runtime Engine", "main": "dist/index.js", "type": "module",