diff --git a/.changeset/few-cobras-pick.md b/.changeset/few-cobras-pick.md new file mode 100644 index 000000000..698db9380 --- /dev/null +++ b/.changeset/few-cobras-pick.md @@ -0,0 +1,7 @@ +--- +'@openfn/cli': minor +--- + +Allow users to specifiy which workflows to deploy or merge by passing `-w`. + +NOTE: the `-w` alias has been repurposed from `--workspace` to `--workflow`. This may affect your local development environment. If so, just expand `-w` to `--workspace`. diff --git a/.changeset/hungry-lights-love.md b/.changeset/hungry-lights-love.md new file mode 100644 index 000000000..232d38d53 --- /dev/null +++ b/.changeset/hungry-lights-love.md @@ -0,0 +1,5 @@ +--- +'@openfn/lexicon': patch +--- + +Allow project.alias to be null diff --git a/.changeset/late-needles-scream.md b/.changeset/late-needles-scream.md new file mode 100644 index 000000000..5b48afa21 --- /dev/null +++ b/.changeset/late-needles-scream.md @@ -0,0 +1,5 @@ +--- +'@openfn/cli': patch +--- + +Fix an issue on checkout where incorrect divergence warnings can be shown diff --git a/.changeset/some-squids-follow.md b/.changeset/some-squids-follow.md new file mode 100644 index 000000000..975ab43cc --- /dev/null +++ b/.changeset/some-squids-follow.md @@ -0,0 +1,5 @@ +--- +'@openfn/lightning-mock': patch +--- + +Ensure that projects added as JSON are deeply cloned, preventing scribbles diff --git a/.changeset/yummy-balloons-search.md b/.changeset/yummy-balloons-search.md new file mode 100644 index 000000000..c6cac63f2 --- /dev/null +++ b/.changeset/yummy-balloons-search.md @@ -0,0 +1,5 @@ +--- +'@openfn/project': patch +--- + +Set the correct alias on the checked out project diff --git a/integration-tests/cli/test/errors.test.ts b/integration-tests/cli/test/errors.test.ts index 666026c8b..315722079 100644 --- a/integration-tests/cli/test/errors.test.ts +++ b/integration-tests/cli/test/errors.test.ts @@ -22,7 +22,6 @@ test.serial('expression not found', async (t) => { const stdlogs = extractLogs(stdout); assertLog(t, stdlogs, /expression not found/i); assertLog(t, stdlogs, /failed to load the expression from blah.js/i); - assertLog(t, stdlogs, /critical error: aborting command/i); }); test.serial('workflow not found', async (t) => { @@ -33,7 +32,6 @@ test.serial('workflow not found', async (t) => { assertLog(t, stdlogs, /workflow not found/i); assertLog(t, stdlogs, /failed to load a workflow from blah.json/i); - assertLog(t, stdlogs, /critical error: aborting command/i); }); test.serial('job contains invalid js', async (t) => { @@ -45,7 +43,6 @@ test.serial('job contains invalid js', async (t) => { assertLog(t, stdlogs, /failed to compile job/i); assertLog(t, stdlogs, /unexpected token \(2:10\)/i); assertLog(t, stdlogs, /check the syntax of the job expression/i); - assertLog(t, stdlogs, /critical error: aborting command/i); }); // TODO this should really mention which job threw the error @@ -60,7 +57,6 @@ test.serial('workflow references a job with invalid js', async (t) => { assertLog(t, stdlogs, /failed to compile job/i); assertLog(t, stdlogs, /unexpected token \(2:10\)/i); assertLog(t, stdlogs, /check the syntax of the job expression/i); - assertLog(t, stdlogs, /critical error: aborting command/i); }); test.serial("can't find an expression referenced in a workflow", async (t) => { @@ -77,7 +73,6 @@ test.serial("can't find an expression referenced in a workflow", async (t) => { stdlogs, /This workflow references a file which cannot be found at does-not-exist.js/i ); - assertLog(t, stdlogs, /critical error: aborting command/i); }); test.serial("can't find config referenced in a workflow", async (t) => { @@ -98,7 +93,6 @@ test.serial("can't find config referenced in a workflow", async (t) => { stdlogs, /This workflow references a file which cannot be found at does-not-exist.js/i ); - assertLog(t, stdlogs, /critical error: aborting command/i); }); test.serial('circular workflow', async (t) => { @@ -141,7 +135,6 @@ test.serial('invalid end (ambiguous)', async (t) => { const stdlogs = extractLogs(stdout); assertLog(t, stdlogs, /Error: end pattern matched multiple steps/i); - assertLog(t, stdlogs, /aborting/i); }); // These test error outputs within valid workflows diff --git a/integration-tests/cli/test/project-v1.test.ts b/integration-tests/cli/test/project-v1.test.ts index 295855b2c..3865d6a54 100644 --- a/integration-tests/cli/test/project-v1.test.ts +++ b/integration-tests/cli/test/project-v1.test.ts @@ -108,7 +108,9 @@ workflows: const projectsPath = path.resolve(TMP_DIR); test.before(async () => { - // await rm(TMP_DIR, { recursive: true }); + try { + await rm(TMP_DIR, { recursive: true }); + } catch (e) {} await mkdir(`${TMP_DIR}/.projects`, { recursive: true }); await writeFile(`${TMP_DIR}/openfn.yaml`, ''); @@ -120,7 +122,7 @@ test.before(async () => { }); test.serial('list available projects', async (t) => { - const { stdout } = await run(`openfn projects -w ${projectsPath}`); + const { stdout } = await run(`openfn projects --workspace ${projectsPath}`); t.regex(stdout, /hello-world/); t.regex(stdout, /8dbc4349-52b4-4bf2-be10-fdf06da52c46/); @@ -130,7 +132,7 @@ test.serial('list available projects', async (t) => { // checkout a project from a yaml file test.serial('Checkout a project', async (t) => { - await run(`openfn checkout hello-world -w ${projectsPath}`); + await run(`openfn checkout hello-world --workspace ${projectsPath}`); // check workflow.yaml const workflowYaml = await readFile( @@ -167,7 +169,7 @@ steps: // note: order of tests is important here test.serial('execute a workflow from the checked out project', async (t) => { // cheeky bonus test of checkout by alias - await run(`openfn checkout main -w ${projectsPath}`); + await run(`openfn checkout main --workspace ${projectsPath}`); // execute a workflow const { stdout } = await run( @@ -191,8 +193,9 @@ test.serial('merge a project', async (t) => { t.is(initial, 'fn(() => ({ x: 1}))'); // Run the merge - await run(`openfn merge hello-world-staging -w ${projectsPath} --force`); - + const { stdout } = await run( + `openfn merge hello-world-staging --workspace ${projectsPath} --force --log debug` + ); // Check the step is updated const merged = await readStep(); t.is(merged, "log('hello world')"); diff --git a/integration-tests/cli/test/project-v2.test.ts b/integration-tests/cli/test/project-v2.test.ts index a41f8791b..8beaf99f8 100644 --- a/integration-tests/cli/test/project-v2.test.ts +++ b/integration-tests/cli/test/project-v2.test.ts @@ -131,7 +131,7 @@ test.before(async () => { }); test.serial('list available projects', async (t) => { - const { stdout } = await run(`openfn projects -w ${TMP_DIR}`); + const { stdout } = await run(`openfn projects --workspace ${TMP_DIR}`); t.regex(stdout, /sandboxing-simple/); t.regex(stdout, /a272a529-716a-4de7-a01c-a082916c6d23/); t.regex(stdout, /staging/); @@ -139,7 +139,7 @@ test.serial('list available projects', async (t) => { }); test.serial('Checkout a project', async (t) => { - await run(`openfn checkout staging -w ${TMP_DIR}`); + await run(`openfn checkout staging --workspace ${TMP_DIR}`); // check workflow.yaml const workflowYaml = await readFile( @@ -174,12 +174,13 @@ steps: test.serial('execute a workflow from the checked out project', async (t) => { // cheeky bonus test of checkout by alias - await run(`openfn checkout main -w ${TMP_DIR}`); + await run(`openfn checkout main --workspace ${TMP_DIR} --force`); // execute a workflow - await run( + const { stdout } = await run( `openfn hello-workflow -o ${TMP_DIR}/output.json --workspace ${TMP_DIR}` ); + console.log(stdout); const output = await readFile(`${TMP_DIR}/output.json`, 'utf8'); const finalState = JSON.parse(output); @@ -189,7 +190,7 @@ test.serial('execute a workflow from the checked out project', async (t) => { test.serial( 'execute a workflow from the checked out project with a credential map', async (t) => { - await run(`openfn checkout main --log debug -w ${TMP_DIR}`); + await run(`openfn checkout main --log debug --workspace ${TMP_DIR}`); // Modify the checked out workflow code await writeFile( @@ -248,7 +249,7 @@ test.serial( // Important: the collection value MUST be as string server.collections.upsert('stuff', 'x', JSON.stringify({ id: 'x' })); - await run(`openfn checkout main --log debug -w ${TMP_DIR}`); + await run(`openfn checkout main --log debug --workspace ${TMP_DIR}`); // Modify the checked out workflow code await writeFile( @@ -313,7 +314,7 @@ workspace: ); test.serial('merge a project', async (t) => { - await run(`openfn checkout main -w ${TMP_DIR}`); + await run(`openfn checkout main --workspace ${TMP_DIR}`); const readStep = () => readFile( @@ -326,7 +327,9 @@ test.serial('merge a project', async (t) => { t.is(initial, 'fn(() => ({ x: 1}))'); // Run the merge - const { stdout } = await run(`openfn merge staging -w ${TMP_DIR} --force`); + const { stdout } = await run( + `openfn merge staging --workspace ${TMP_DIR} --force` + ); // Check the step is updated const merged = await readStep(); diff --git a/packages/cli/src/projects/checkout.ts b/packages/cli/src/projects/checkout.ts index 883feb128..f622b2a0e 100644 --- a/packages/cli/src/projects/checkout.ts +++ b/packages/cli/src/projects/checkout.ts @@ -10,12 +10,9 @@ import * as o from '../options'; import * as po from './options'; import type { Opts } from './options'; -import { - findLocallyChangedWorkflows, - tidyWorkflowDir, - updateForkedFrom, -} from './util'; +import { tidyWorkflowDir, updateForkedFrom } from './util'; import { createProjectCredentials } from './create-credentials'; +import abort from '../util/abort'; export type CheckoutOptions = Pick< Opts, @@ -52,7 +49,11 @@ export const handler = async (options: CheckoutOptions, logger?: Logger) => { // TODO: try to retain the endpoint for the projects const { project: _, ...config } = workspace.getConfig() as any; - const currentProject = await workspace.getCheckedOutProject(); + const localProject = await workspace.getCheckedOutProject( + // TODO not sold on this assignment - I think my test case must be wrong + workspace.activeProject?.alias as any + ); + // get the project let switchProject; if (/\.(yaml|json)$/.test(projectIdentifier)) { @@ -71,34 +72,33 @@ export const handler = async (options: CheckoutOptions, logger?: Logger) => { `Project with id ${projectIdentifier} not found in the workspace` ); } + logger?.info(`Checking out ${switchProject.alias}`); // get the current state of the checked out project try { - const localProject = await Project.from('fs', { - root: options.workspace || '.', - }); - logger?.success(`Loaded local project ${localProject.alias}`); - const changed = await findLocallyChangedWorkflows( - workspace, - localProject, - 'assume-ok' - ); - if (changed.length && !options.force) { - logger?.break(); - logger?.warn( - 'WARNING: detected changes on your currently checked-out project' - ); - logger?.warn( - `Changes may be lost by checking out ${localProject.alias} right now` + // If there's no project checked out, there's nothing to compare + if (localProject?.workflows.length) { + logger?.info( + `Loaded currently checked out project ${localProject.alias} to check for untracked changes` ); - logger?.warn(`Pass --force or -f to override this warning and continue`); - // TODO log to run with force - // TODO need to implement a save function - const e = new Error( - `The currently checked out project has diverged! Changes may be lost` - ); - delete e.stack; - throw e; + // TODO is alias robust here? Should we get by alias and domain? + const tracked = workspace.get(localProject.alias ?? localProject.id); + const changed = hasUntrackedChanges(localProject, tracked); + logger?.debug(changed); + if (changed.length && !options.force) { + const err = { + details: `Changes may be lost by checking out ${ + localProject.alias ?? localProject.id + } right now`, + // TODO how can users save changes? Not really possible right now + fix: 'Pass --force or -f to override this warning and continue', + }; + abort( + logger!, + `${switchProject.alias} has diverged from ${localProject.alias}!`, + err + ); + } } } catch (e: any) { if (e.message.match('ENOENT')) { @@ -113,7 +113,7 @@ export const handler = async (options: CheckoutOptions, logger?: Logger) => { if (options.clean) { await rimraf(workspace.workflowsPath); } else { - await tidyWorkflowDir(currentProject, switchProject, false, workspacePath); + await tidyWorkflowDir(localProject, switchProject, false, workspacePath); } // write the forked from map @@ -137,3 +137,47 @@ export const handler = async (options: CheckoutOptions, logger?: Logger) => { logger?.success(`Expanded project to ${workspacePath}`); }; + +// This function will tell us if the active/checked out project +// has any changes compared to the tracked state file +// It implies that changes will be lost on checkout +// (later, users can save a project to an arbitrary save file and so this may not be true) +const hasUntrackedChanges = ( + activeProject: Project, + tracked?: Project | null +) => { + if (!tracked) { + // if there's no tracking we can't compare + // should we log a warning then? + return []; + } + + const changedWorkflows: Array<{ + id: string; + type: 'new' | 'changed' | 'removed'; + }> = []; + + // Check for changed and added workflows + for (const workflow of activeProject.workflows) { + const trackedWorkflow = tracked.getWorkflow(workflow.id); + if (!trackedWorkflow) { + // this is a new workflow added locally + changedWorkflows.push({ id: workflow.id, type: 'new' }); + continue; + } + + if (!tracked.canMergeInto(activeProject)) { + changedWorkflows.push({ id: workflow.id, type: 'changed' }); + } + } + + // Check for removed workflows + for (const workflow of tracked.workflows) { + const localWorkflow = activeProject.getWorkflow(workflow.id); + if (!localWorkflow) { + changedWorkflows.push({ id: workflow.id, type: 'removed' }); + } + } + + return changedWorkflows; +}; diff --git a/packages/cli/src/projects/deploy.ts b/packages/cli/src/projects/deploy.ts index 03f4e21eb..ad8aea53d 100644 --- a/packages/cli/src/projects/deploy.ts +++ b/packages/cli/src/projects/deploy.ts @@ -1,5 +1,9 @@ import yargs from 'yargs'; -import Project, { versionsEqual, Workspace } from '@openfn/project'; +import Project, { + MergeProjectOptions, + versionsEqual, + Workspace, +} from '@openfn/project'; import { writeFile } from 'node:fs/promises'; import path from 'node:path'; @@ -42,6 +46,7 @@ export type DeployOptions = Pick< name?: string; alias?: string; jsonDiff?: boolean; + workflow?: string[]; }; const options = [ @@ -53,6 +58,7 @@ const options = [ o2.name, o2.alias, o2.jsonDiff, + o2.workflow, // general options o.apiKey, @@ -170,14 +176,26 @@ const syncProjects = async ( // this will actually happen later } - const locallyChangedWorkflows = await findLocallyChangedWorkflows( - ws, - localProject - ); + let mergeCandidates: string[]; + if (options.workflow?.length) { + const missing = options.workflow.filter( + (id) => !localProject.workflows.some((w) => w.id === id) + ); + if (missing.length) { + throw new Error( + `The following workflows were not found in local project ${ + localProject.id + }: ${missing.join(', ')}` + ); + } + mergeCandidates = options.workflow; + } else { + mergeCandidates = await findLocallyChangedWorkflows(ws, localProject); + } // TODO: what if remote diff and the version checked disagree for some reason? - const diffs = locallyChangedWorkflows.length - ? remoteProject.diff(localProject, locallyChangedWorkflows) + const diffs = mergeCandidates.length + ? remoteProject.diff(localProject, mergeCandidates) : []; if (!diffs.length) { @@ -203,7 +221,7 @@ const syncProjects = async ( const divergentWorkflows = hasRemoteDiverged( localProject, remoteProject!, - locallyChangedWorkflows + mergeCandidates ); if (divergentWorkflows) { logger.warn( @@ -231,16 +249,24 @@ const syncProjects = async ( } logger.info('Merging changes into remote project'); - // TODO I would like to log which workflows are being updated - const merged = Project.merge(localProject, remoteProject!, { + const mergeOptions: MergeProjectOptions = { // If pushing the same project, we use a replace strategy // Otherwise, use the sandbox strategy to preserve UUIDs mode: localProject.uuid === remoteProject.uuid ? 'replace' : 'sandbox', force: true, - onlyUpdated: true, - }); + }; + if (options.workflow?.length) { + // If --workflow is passed, force-include exactly the listed workflows via workflowMappings + mergeOptions.workflowMappings = Object.fromEntries( + options.workflow.map((id) => [id, id]) + ); + } else { + // Otherwise only merge locally updated workflows + mergeOptions.onlyUpdated = true; + } + const merged = Project.merge(localProject, remoteProject!, mergeOptions); - return { merged, remoteProject, locallyChangedWorkflows }; + return { merged, remoteProject, locallyChangedWorkflows: mergeCandidates }; }; export async function handler(options: DeployOptions, logger: Logger) { diff --git a/packages/cli/src/projects/list.ts b/packages/cli/src/projects/list.ts index 06d091c59..932ad182e 100644 --- a/packages/cli/src/projects/list.ts +++ b/packages/cli/src/projects/list.ts @@ -7,6 +7,7 @@ import * as o from '../options'; import * as po from './options'; import type { Opts } from './options'; +import abort from '../util/abort'; export type ProjectListOptions = Pick; @@ -34,7 +35,9 @@ export const handler = async (options: ProjectListOptions, logger: Logger) => { // eg, this will happen if there's no openfn.yaml file // basically we need the workspace to return a reason // (again, I'm thinking of removing the validation entirely) - throw new Error('No OpenFn projects found'); + abort(logger, `No OpenFn projects found at ${options.workspace}`, { + fix: 'Run this command from a folder with an openfn.yaml file, or pass --workspace to set the workspace root', + }); } logger.always(`Available openfn projects\n\n${workspace diff --git a/packages/cli/src/projects/merge.ts b/packages/cli/src/projects/merge.ts index 7c9837c7d..d036cff8d 100644 --- a/packages/cli/src/projects/merge.ts +++ b/packages/cli/src/projects/merge.ts @@ -17,11 +17,12 @@ export type MergeOptions = Required< 'command' | 'project' | 'workspace' | 'removeUnmapped' | 'workflowMappings' > > & - Pick & { base?: string }; + Pick & { base?: string }; const options = [ po.removeUnmapped, po.workflowMappings, + po.workflow, po.workspace, o.log, // custom output because we don't want defaults or anything @@ -49,8 +50,8 @@ const options = [ const command: yargs.CommandModule = { command: 'merge ', - describe: - 'Merges the currently checked-out project into the target project, and checks out the result. Does not update the remote project or local project.yaml file', + describe: false, + //describe: 'Merges the currently checked-out project into the target project, and checks out the result. Does not update the remote project or local project.yaml file', handler: ensure('project-merge', options), builder: (yargs) => build(options, yargs), }; @@ -109,6 +110,29 @@ export const handler = async (options: MergeOptions, logger: Logger) => { logger.error('The checked out project has no id'); return; } + + let workflowMappings = options.workflowMappings; + if (options.workflow?.length) { + if (workflowMappings && Object.keys(workflowMappings).length) { + logger.error('--workflow and --workflow-mappings are mutually exclusive'); + return; + } + const missing = options.workflow.filter( + (id) => !sourceProject.workflows.some((w) => w.id === id) + ); + if (missing.length) { + logger.error( + `The following workflows were not found in source project ${ + sourceProject.id + }: ${missing.join(', ')}` + ); + return; + } + workflowMappings = Object.fromEntries( + options.workflow.map((id) => [id, id]) + ); + } + const finalPath = options.outputPath ?? workspace.getProjectPath(targetProject.id); if (!finalPath) { @@ -117,7 +141,7 @@ export const handler = async (options: MergeOptions, logger: Logger) => { } const final = Project.merge(sourceProject, targetProject, { removeUnmapped: options.removeUnmapped, - workflowMappings: options.workflowMappings, + workflowMappings, force: options.force, }); @@ -147,6 +171,8 @@ export const handler = async (options: MergeOptions, logger: Logger) => { workspace: workspacePath, project: options.outputPath ? finalPath : final.id, log: options.log, + // after the merge, we have to force the output to be checked out, ignoring divergence + force: true, }, logger ); diff --git a/packages/cli/src/projects/options.ts b/packages/cli/src/projects/options.ts index ba5ecbfd4..d38400574 100644 --- a/packages/cli/src/projects/options.ts +++ b/packages/cli/src/projects/options.ts @@ -8,6 +8,7 @@ export type Opts = BaseOpts & { workspace?: string; removeUnmapped?: boolean | undefined; workflowMappings?: Record | undefined; + workflow?: string[]; project?: string; format?: 'yaml' | 'json' | 'state'; clean?: boolean; @@ -86,6 +87,22 @@ export const workflowMappings: CLIOption = { }, }; +export const workflow: CLIOption = { + name: 'workflow', + yargs: { + alias: ['w'], + array: true, + description: + 'Restrict merge/deploy to the given workflow ids. Pass multiple times to include multiple workflows. Listed workflows are force-included from the source and will overwrite the target/remote even if unchanged locally. Mutually exclusive with --workflow-mappings.', + }, + ensure: (opts: any) => { + if (opts.workflow?.length) { + opts.workflow = Array.from(new Set(opts.workflow)); + } + delete opts.w; + }, +}; + // We declare a new output path here, overriding the default cli one, // because default rules are different export const outputPath: CLIOption = { @@ -100,7 +117,6 @@ export const outputPath: CLIOption = { export const workspace: CLIOption = { name: 'workspace', yargs: { - alias: ['w'], description: 'Path to the project workspace (ie, path to openfn.yaml)', }, ensure: (opts: any) => { diff --git a/packages/cli/src/projects/util.ts b/packages/cli/src/projects/util.ts index abe1f7e7b..7f446fd22 100644 --- a/packages/cli/src/projects/util.ts +++ b/packages/cli/src/projects/util.ts @@ -259,6 +259,8 @@ export const updateForkedFrom = (proj: Project) => { return proj; }; +// Compare a project to its version hashed when forked +// This tells us whether the project was edited since it was created export const findLocallyChangedWorkflows = async ( workspace: Workspace, project: Project, @@ -266,7 +268,6 @@ export const findLocallyChangedWorkflows = async ( ) => { // Check openfn.yaml for the forked_from versions const { forked_from } = workspace.activeProject ?? {}; - // If there are no forked_from references, we have no baseline // so assume everything has changed if (!forked_from || Object.keys(forked_from).length === 0) { diff --git a/packages/cli/src/util/abort.ts b/packages/cli/src/util/abort.ts index 83aa44950..aecc3464e 100644 --- a/packages/cli/src/util/abort.ts +++ b/packages/cli/src/util/abort.ts @@ -16,24 +16,24 @@ interface CLIFriendlyError extends Error { export default ( logger: Logger, reason: string, - error?: CLIFriendlyError, + error?: Partial, help?: string ) => { const e = new AbortError(reason); logger.break(); logger.error(reason); if (error) { - logger.error(error.message); - logger.break(); + if (error.message) { + logger.error(error.message); + logger.break(); + } if (error.details) { - logger.error('ERROR DETAILS:'); logger.error(error.details); logger.break(); } if (error.fix) { - logger.error('FIX HINT:'); - logger.error(error.fix); + logger.always(error.fix); logger.break(); } } @@ -41,7 +41,7 @@ export default ( logger.always(help); } logger.break(); - logger.error('Critical error: aborting command'); + // logger.error('Critical error: aborting command'); process.exitCode = 1; diff --git a/packages/cli/test/compile/compile.test.ts b/packages/cli/test/compile/compile.test.ts index 1265fb44a..6acdb9bf8 100644 --- a/packages/cli/test/compile/compile.test.ts +++ b/packages/cli/test/compile/compile.test.ts @@ -89,7 +89,6 @@ test.serial('throw an AbortError if a job is uncompilable', async (t) => { t.assert(logger._find('error', /unexpected token/i)); t.assert(logger._find('always', /check the syntax of the job expression/i)); - t.assert(logger._find('error', /critical error: aborting command/i)); }); test.serial( @@ -111,7 +110,6 @@ test.serial( t.assert(logger._find('error', /unexpected token/i)); t.assert(logger._find('always', /check the syntax of the job expression/i)); - t.assert(logger._find('error', /critical error: aborting command/i)); } ); diff --git a/packages/cli/test/projects/checkout.test.ts b/packages/cli/test/projects/checkout.test.ts index 3a110c5bf..628bcad8c 100644 --- a/packages/cli/test/projects/checkout.test.ts +++ b/packages/cli/test/projects/checkout.test.ts @@ -530,6 +530,8 @@ test.serial( command: 'project-checkout', project: 'main-project', workspace: '/ws3', + // the project on-disk has diverged from the statefile, so we need to force it through + force: true, }, logger ); @@ -570,6 +572,8 @@ test.serial( command: 'project-checkout', project: 'main-project', workspace: '/ws4', + // the project on-disk has diverged from the statefile, so we need to force it through + force: true, }, logger ); @@ -714,6 +718,8 @@ test.serial( command: 'project-checkout', project: 'main-project', workspace: '/ws5', + // the project on-disk has diverged from the statefile, so we need to force it through + force: true, }, logger ); @@ -722,3 +728,269 @@ test.serial( t.true(fs.existsSync('/ws5/workflows/workflow-a')); } ); + +/** + * Using projects foo and bar here which come from a real issue + * Keeping those exact state files to keep diversity in the tests + */ +const foo = `id: foo +name: foo +schema_version: '4.0' +collections: [] +channels: [] +credentials: + - uuid: 8c675997-117b-4e8a-a65e-1ddea0d0e525 + name: name + owner: editor@openfn.org +openfn: + uuid: 44c0c920-5635-4984-ade2-b95fb24cbaf0 + endpoint: http://localhost:4000 + inserted_at: 2025-10-15T11:29:36Z + updated_at: 2026-03-17T11:59:53Z +options: + env: main + allow_support_access: false + requires_mfa: false + retention_policy: retain_all +workflows: + - name: A + steps: + - id: aaa + name: aaa + expression: // abc + adaptor: '@openfn/language-common@latest' + openfn: + uuid: 7b6a6de4-eed2-4204-8ac0-4da8fa64206c + next: + bbb: + disabled: false + condition: on_job_success + openfn: + uuid: 64f1b20f-bfdf-4626-87de-403008cfb05d + - id: bbb + name: bbb + expression: '2' + adaptor: '@openfn/language-common@3.3.1' + openfn: + uuid: 832f5560-69c5-4eae-89cc-823b93af82c8 + - id: webhook + type: webhook + enabled: true + webhook_reply: before_start + openfn: + uuid: 16ddedbb-1d70-44b7-8653-26f8dc802757 + next: + aaa: + disabled: false + condition: always + openfn: + uuid: eccb03ef-990d-4ca7-877b-5452bbc8f63b + history: + - app:0a97362c97b3 + - app:8eb248f07744 + openfn: + uuid: 4b2c13aa-2497-421a-9bb2-783309254130 + updated_at: 2026-05-14T10:25:36Z + inserted_at: 2026-05-14T10:25:10Z + lock_version: 6 + id: a + start: webhook +`; +const bar = `id: bar +name: bar +schema_version: '4.0' +cli: + forked_from: + a: cli:145ff1ae62e5 +collections: [] +channels: [] +credentials: [] +openfn: + uuid: 7c478de6-4c82-427d-aad2-875b1b9eccb8 + endpoint: http://localhost:4000 + alias: staging + inserted_at: 2026-05-26T16:27:05Z + updated_at: 2026-05-26T16:27:05Z +options: + allow_support_access: false + requires_mfa: false + retention_policy: retain_all +workflows: + - name: A + steps: + - id: aaa + name: aaa + expression: // 2 + adaptor: '@openfn/language-common@latest' + openfn: + uuid: 8227ae53-81f8-447f-bb93-213d5721f884 + next: + bbb: + disabled: false + condition: on_job_success + openfn: + uuid: 474d6861-bb47-4fad-953d-a7762751bae0 + - id: bbb + name: bbb + expression: '2' + adaptor: '@openfn/language-http@7.2.11' + openfn: + uuid: 862bec16-ef94-4438-b307-8594a70276fe + - id: webhook + type: webhook + enabled: false + webhook_reply: before_start + openfn: + uuid: d7dfdd68-ecb8-4adc-90cf-8a4ed8cc0235 + next: + aaa: + disabled: false + condition: always + openfn: + uuid: 067cab97-bef8-4d70-b484-5d013d27142b + history: + - cli:145ff1ae62e5 + openfn: + uuid: 9746c1d9-1499-4413-9edc-c23577e9308e + inserted_at: 2026-05-26T16:27:05Z + updated_at: 2026-05-26T16:27:05Z + lock_version: 1 + id: a + start: webhook +`; + +test.serial( + 'Checkout unrelated bar from unrelated project foo without divergence warning', + async (t) => { + mock({ + '/tmp/openfn.yaml': '', + '/tmp/.projects/main@server.yaml': foo, + '/tmp/.projects/staging@server.yaml': bar, + }); + + // first checkout foo to set up the file system + await checkoutHandler( + { + command: 'project-checkout', + project: 'foo', + workspace: '/tmp', + }, + logger + ); + + // assert that staging was checked out ok + let openfn = yamlToJson(fs.readFileSync('/tmp/openfn.yaml', 'utf8')); + t.is(openfn.project.id, 'foo'); + + let expression = fs.readFileSync('/tmp/workflows/a/aaa.js', 'utf8'); + t.is(expression, '// abc'); + + // now checkout bar + await checkoutHandler( + { + command: 'project-checkout', + project: 'bar', + workspace: '/tmp', + }, + logger + ); + logger._reset(); + + // assert that main was checked out ok + openfn = yamlToJson(fs.readFileSync('/tmp/openfn.yaml', 'utf8')); + t.is(openfn.project.id, 'bar'); + + expression = fs.readFileSync('/tmp/workflows/a/aaa.js', 'utf8'); + t.is(expression, '// 2'); + } +); + +test.serial( + 'Checkout unrelated foo from unrelated project bar without divergence warning', + async (t) => { + mock({ + '/tmp/openfn.yaml': '', + '/tmp/.projects/main@server.yaml': foo, + '/tmp/.projects/staging@server.yaml': bar, + }); + + // first checkout bar to set up the file system + await checkoutHandler( + { + command: 'project-checkout', + project: 'bar', + workspace: '/tmp', + }, + logger + ); + logger._reset(); + + // assert that main was checked out ok + let openfn = yamlToJson(fs.readFileSync('/tmp/openfn.yaml', 'utf8')); + t.is(openfn.project.id, 'bar'); + + let expression = fs.readFileSync('/tmp/workflows/a/aaa.js', 'utf8'); + t.is(expression, '// 2'); + + // now checkout foo + await checkoutHandler( + { + command: 'project-checkout', + project: 'foo', + workspace: '/tmp', + }, + logger + ); + + // assert that staging was checked out ok + openfn = yamlToJson(fs.readFileSync('/tmp/openfn.yaml', 'utf8')); + t.is(openfn.project.id, 'foo'); + + expression = fs.readFileSync('/tmp/workflows/a/aaa.js', 'utf8'); + t.is(expression, '// abc'); + } +); + +test.serial( + 'If the checked out project has diverged from the tracked version, show a divergence warning on checkout', + async (t) => { + mock({ + '/tmp/openfn.yaml': '', + '/tmp/.projects/main@server.yaml': foo, + '/tmp/.projects/staging@server.yaml': bar, + }); + + await checkoutHandler( + { + command: 'project-checkout', + project: 'bar', + workspace: '/tmp', + }, + logger + ); + logger._reset(); + + // assert that main was checked out ok + let openfn = yamlToJson(fs.readFileSync('/tmp/openfn.yaml', 'utf8')); + t.is(openfn.project.id, 'bar'); + + // Now make a change - on checkout, this change will be lost (it is not saved anywhere) + fs.writeFileSync('/tmp/workflows/a/aaa.js', 'foobar'); + + // now try to checkout foo + await t.throwsAsync( + () => + checkoutHandler( + { + command: 'project-checkout', + project: 'foo', + workspace: '/tmp', + }, + logger + ), + { + message: 'main has diverged from staging!', + } + ); + } +); diff --git a/packages/cli/test/projects/deploy.test.ts b/packages/cli/test/projects/deploy.test.ts index 2be2f28c2..b0c1774e5 100644 --- a/packages/cli/test/projects/deploy.test.ts +++ b/packages/cli/test/projects/deploy.test.ts @@ -11,8 +11,15 @@ import { hasRemoteDiverged, } from '../../src/projects/deploy'; import { printRichDiff } from '../../src/projects/diff'; -import { myProject_yaml, myProject_v1, UUID } from './fixtures'; +import { + myProject_yaml, + myProject_v1, + UUID, + two_workflows_yaml as twowfs, + TWO_WORKFLOWS_UUID, +} from './fixtures'; import { checkout } from '../../src/projects'; +import { readFileSync } from 'node:fs'; let server: any; const logger = createMockLogger(undefined, { level: 'debug' }); @@ -21,11 +28,17 @@ const ENDPOINT = `http://localhost:${port}`; // quick fix to the fixture yaml, otherwise the deploy code kicks off const projectYaml = myProject_yaml.replace('https://app.openfn.org', ENDPOINT); +const two_workflows_yaml = twowfs.replace('https://app.openfn.org', ENDPOINT); const mockFs = (paths: Record) => { - const pnpm = path.resolve('../../node_modules/.pnpm'); + // ensure this path is available to pnpm (needed by deps for some reason??) + // Note: loading all of pnpm takes ~7 seconds per test + // this workaround cuts out that delay entirely + const iconv = path.resolve( + '../../node_modules/.pnpm/iconv-lite@0.4.24/node_modules/iconv-lite/encodings' + ); mock({ - [pnpm]: mock.load(pnpm, {}), + [iconv]: mock.load(iconv, {}), ...paths, }); }; @@ -194,6 +207,158 @@ test.serial( } ); +test.serial( + 'Passing --workflow only updates the requested workflows', + async (t) => { + await server.addProject(two_workflows_yaml); + await setup(two_workflows_yaml); + + // Change both workflows locally + await writeFile('/ws/workflows/workflow-a/job-a.js', 'modifiedA()'); + await writeFile('/ws/workflows/workflow-b/job-b.js', 'modifiedB()'); + + await deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + confirm: false, + workflow: ['workflow-a'], + } as any, + logger + ); + + const remoteProject = server.state.projects[TWO_WORKFLOWS_UUID]; + t.is( + remoteProject.workflows['workflow-a'].jobs['job-a'].body, + 'modifiedA()' + ); + t.is(remoteProject.workflows['workflow-b'].jobs['job-b'].body, 'fn()'); + } +); + +test.serial( + '--workflow errors when an id is not in the local project', + async (t) => { + await setup(projectYaml); + + await t.throwsAsync( + () => + deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + confirm: false, + workflow: ['nope-not-a-real-workflow'], + } as any, + logger + ), + { message: /nope-not-a-real-workflow/ } + ); + } +); + +test.serial( + '--workflow only actually updates a workflow if it has changed', + async (t) => { + t.truthy(server.state.projects[UUID]); + await setup(projectYaml); + + await deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + confirm: false, + workflow: ['my-workflow'], + } as any, + logger + ); + + // TODO better to check that there is no post request tbh + t.truthy(logger._find('success', /Nothing to deploy/)); + } +); + +test.serial( + '--workflow will overwrite a newer version on the target if --force is included', + async (t) => { + t.truthy(server.state.projects[UUID]); + await setup(projectYaml); + + // Assert that the original remote code is fn() + const ogTransformData = + server.state.projects[UUID].workflows['my-workflow'].jobs[ + 'transform-data' + ]; + t.is(ogTransformData.body, 'fn()'); + + // Modify the remote + const modified = JSON.parse( + JSON.stringify(server.state.projects[UUID].workflows['my-workflow']) + ); + modified.jobs['transform-data'].body = 'each()'; + server.updateWorkflow(UUID, modified); + + const changedTransformData = + server.state.projects[UUID].workflows['my-workflow'].jobs[ + 'transform-data' + ]; + t.is(changedTransformData.body, 'each()'); + + // Force push local (which will revert the remote changed) + await deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + confirm: false, + workflow: ['my-workflow'], + force: true, + } as any, + logger + ); + + t.truthy(logger._find('success', /Updated project at/)); + + // The remote should have been overwritten with the local body + const mergedTransformData = + server.state.projects[UUID].workflows['my-workflow'].jobs[ + 'transform-data' + ]; + t.is(mergedTransformData.body, 'fn()'); + } +); + +test.serial( + '--workflow still errors on divergence without --force', + async (t) => { + await setup(projectYaml); + + const modified = JSON.parse( + JSON.stringify(server.state.projects[UUID].workflows['my-workflow']) + ); + modified.jobs['transform-data'].body = 'each()'; + server.updateWorkflow(UUID, modified); + + await t.throwsAsync( + () => + deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + confirm: false, + workflow: ['my-workflow'], + } as any, + logger + ), + { message: /PROJECTS_DIVERGED/ } + ); + } +); + test('printRichDiff: should report no changes for identical projects', (t) => { const wf = generateWorkflow('@id a trigger-x'); diff --git a/packages/cli/test/projects/fixtures.ts b/packages/cli/test/projects/fixtures.ts index b67073a37..64f7e350f 100644 --- a/packages/cli/test/projects/fixtures.ts +++ b/packages/cli/test/projects/fixtures.ts @@ -103,3 +103,72 @@ workflows: lock_version: 1 id: my-workflow start: webhook`; + +export const TWO_WORKFLOWS_UUID = '4b09ddf1-35f4-4e40-9aa9-0d80c086dd9e'; + +export const two_workflows_yaml = `id: my-project +name: My Project +schema_version: '4.0' +description: '' +collections: [] +credentials: [] +openfn: + uuid: ${TWO_WORKFLOWS_UUID} + endpoint: https://app.openfn.org + inserted_at: 2025-04-23T11:15:59Z + updated_at: 2025-04-23T11:15:59Z +options: + allow_support_access: false + requires_mfa: false + retention_policy: retain_all +workflows: + - id: workflow-a + name: Workflow A + steps: + - id: job-a + name: Job A + expression: fn() + adaptor: '@openfn/language-common@latest' + openfn: + uuid: 3d4727b6-4052-4f58-a834-3a03e433ff1d + - id: trigger-a + type: webhook + enabled: true + openfn: + uuid: 1b1c1dd5-e8d9-432f-aeaf-4e09397cac98 + next: + job-a: + condition: always + openfn: + uuid: 1118353a-6015-40f9-8e57-51801a65bcfc + openfn: + uuid: 4584df01-cab4-4182-974d-6a75b13c7b97 + inserted_at: 2025-04-23T11:19:32Z + updated_at: 2025-04-23T11:19:32Z + lock_version: 1 + start: trigger-a + - id: workflow-b + name: Workflow B + steps: + - id: job-b + name: Job B + expression: fn() + adaptor: '@openfn/language-common@latest' + openfn: + uuid: 37e6e616-3840-4d71-b63c-a736ebc208b7 + - id: trigger-b + type: webhook + enabled: true + openfn: + uuid: d65ed915-7f39-428b-af57-57ed2ecf507e + next: + job-b: + condition: always + openfn: + uuid: 4b291d27-c055-40cd-b82d-210644338715 + openfn: + uuid: fc5eeff6-537b-4667-841b-4d17c70dfab9 + inserted_at: 2025-04-23T11:19:32Z + updated_at: 2025-04-23T11:19:32Z + lock_version: 1 + start: trigger-b`; diff --git a/packages/cli/test/projects/list.test.ts b/packages/cli/test/projects/list.test.ts index 6b4b2d05e..0891e5038 100644 --- a/packages/cli/test/projects/list.test.ts +++ b/packages/cli/test/projects/list.test.ts @@ -135,7 +135,7 @@ test('throw for invalid workspace directory', async (t) => { await t.throwsAsync( () => list({ command: 'projects', workspace: '/invalid' }, logger), { - message: 'No OpenFn projects found', + message: 'No OpenFn projects found at /invalid', } ); // const { message } = logger._parse(logger._last); @@ -146,7 +146,7 @@ test('throw if dir is not a workspace', async (t) => { await t.throwsAsync( () => list({ command: 'projects', workspace: '/no-ws' }, logger), { - message: 'No OpenFn projects found', + message: 'No OpenFn projects found at /no-ws', } ); }); diff --git a/packages/cli/test/projects/merge.test.ts b/packages/cli/test/projects/merge.test.ts index e27aa59f6..90bcfbbd2 100644 --- a/packages/cli/test/projects/merge.test.ts +++ b/packages/cli/test/projects/merge.test.ts @@ -321,3 +321,132 @@ test.serial('merge with custom base', async (t) => { t.is(merged.workflows[0].steps[1].name, 'Job X'); t.is(merged.workflows[0].steps[1].openfn?.uuid, 'job-a'); // id got retained }); + +// Multi-workflow fixtures used by --workflows tests +const buildWorkflow = (id: string, jobId: string, jobName: string) => ({ + name: id, + id, + jobs: [{ id: jobId, name: jobName }], + triggers: [{ type: 'cron', enabled: true, id: `${id}-trigger` }], + edges: [ + { + id: `${id}-edge`, + target_job_id: jobId, + enabled: true, + source_trigger_id: `${id}-trigger`, + condition_type: 'always', + }, + ], +}); + +const multiSandbox = { + id: '', + name: 'My Sandbox', + workflows: [ + buildWorkflow('workflow-1', 'job-x', 'Job X (from sandbox)'), + buildWorkflow('workflow-2', 'job-y', 'Job Y (from sandbox)'), + ], +}; + +const multiMain = { + id: '', + name: 'My Project', + workflows: [ + buildWorkflow('workflow-1', 'job-a', 'Job A (from main)'), + buildWorkflow('workflow-2', 'job-b', 'Job B (from main)'), + ], +}; + +const mockMultiWorkflowWorkspace = () => { + mock({ + '/ws/workflows': {}, + '/ws/openfn.yaml': jsonToYaml({ + project: { id: 'my-project', name: 'My Project' }, + workspace: { + dirs: { workflows: 'workflows' }, + formats: { openfn: 'yaml', project: 'yaml', workflow: 'yaml' }, + }, + }), + '/ws/.projects/staging@app.openfn.org.yaml': jsonToYaml(multiSandbox), + '/ws/.projects/project@app.openfn.org.yaml': jsonToYaml(multiMain), + }); +}; + +test.serial( + '--workflow merges only the listed workflow, leaving other target workflows untouched', + async (t) => { + mockMultiWorkflowWorkspace(); + + await mergeHandler( + { + command: 'project-merge', + workspace: '/ws', + project: 'my-sandbox', + removeUnmapped: false, + workflowMappings: {}, + workflow: ['workflow-1'], + }, + logger + ); + + const merged = await Project.from( + 'path', + '/ws/.projects/project@app.openfn.org.yaml' + ); + + const wf1 = merged.workflows.find((w) => w.id === 'workflow-1')!; + const wf2 = merged.workflows.find((w) => w.id === 'workflow-2')!; + + // workflow-1 was merged from sandbox (Job X overlaid) + t.truthy(wf1.steps.find((s) => s.name === 'Job X (from sandbox)')); + // workflow-2 was NOT touched - still has Job B from main, not Job Y from sandbox + t.truthy(wf2.steps.find((s) => s.name === 'Job B (from main)')); + t.falsy(wf2.steps.find((s) => s.name === 'Job Y (from sandbox)')); + } +); + +test.serial( + '--workflow errors when an id is not in the source project', + async (t) => { + mockMultiWorkflowWorkspace(); + + await mergeHandler( + { + command: 'project-merge', + workspace: '/ws', + project: 'my-sandbox', + removeUnmapped: false, + workflowMappings: {}, + workflow: ['workflow-1', 'does-not-exist'], + }, + logger + ); + + const { message, level } = logger._parse(logger._last); + t.is(level, 'error'); + t.regex(message as string, /does-not-exist/); + } +); + +test.serial( + '--workflow and --workflow-mappings are mutually exclusive', + async (t) => { + mockMultiWorkflowWorkspace(); + + await mergeHandler( + { + command: 'project-merge', + workspace: '/ws', + project: 'my-sandbox', + removeUnmapped: false, + workflowMappings: { 'workflow-1': 'workflow-1' }, + workflow: ['workflow-1'], + }, + logger + ); + + const { message, level } = logger._parse(logger._last); + t.is(level, 'error'); + t.regex(message as string, /mutually exclusive/); + } +); diff --git a/packages/lexicon/core.d.ts b/packages/lexicon/core.d.ts index bdf6be58c..1158ffb69 100644 --- a/packages/lexicon/core.d.ts +++ b/packages/lexicon/core.d.ts @@ -94,7 +94,7 @@ export interface LocalMeta { This only affects how a state file ondisk is parsed */ version?: number; /** Shorthand identifier used by CLI commands */ - alias?: string; + alias?: string | null; [key: string]: any; } diff --git a/packages/lightning-mock/src/api-dev.ts b/packages/lightning-mock/src/api-dev.ts index b6de55f0a..be27910a1 100644 --- a/packages/lightning-mock/src/api-dev.ts +++ b/packages/lightning-mock/src/api-dev.ts @@ -72,7 +72,10 @@ const setupDevAPI = ( project = proj.serialize('state', { format: 'json', }) as Provisioner.Project_v1; + } else { + project = JSON.parse(JSON.stringify(project)); } + // @ts-ignore state.projects[project.id] = project; }; diff --git a/packages/project/src/Project.ts b/packages/project/src/Project.ts index 749600b26..c1a401db4 100644 --- a/packages/project/src/Project.ts +++ b/packages/project/src/Project.ts @@ -36,7 +36,7 @@ type UUIDMap = { type CLIMeta = { version?: number; - alias?: string; + alias?: string | null; forked_from?: Record; }; @@ -169,11 +169,11 @@ export class Project { } /** Local alias for the project. Comes from the file name. Not shared with Lightning. */ - get alias() { - return this.cli.alias ?? 'main'; + get alias(): string | null { + return this.cli.alias ?? null; } - set alias(value: string) { + set alias(value: string | null) { this.cli ??= {}; this.cli.alias = value; } diff --git a/packages/project/src/Workflow.ts b/packages/project/src/Workflow.ts index 8fcce5868..b4d42d237 100644 --- a/packages/project/src/Workflow.ts +++ b/packages/project/src/Workflow.ts @@ -208,7 +208,6 @@ class Workflow { this.workflow.history?.concat(this.getVersionHash()) ?? []; const targetHistory = target.workflow.history?.concat(target.getVersionHash()) ?? []; - const targetHead = targetHistory[targetHistory.length - 1]; return thisHistory.indexOf(targetHead) > -1; } diff --git a/packages/project/src/Workspace.ts b/packages/project/src/Workspace.ts index 0f7eb847b..f7e2bc3ec 100644 --- a/packages/project/src/Workspace.ts +++ b/packages/project/src/Workspace.ts @@ -48,6 +48,13 @@ export class Workspace { } } this.config = buildConfig(context.workspace); + + // TODO: work out the alias of the active project + // and make sure it's written + // tbh as activeProject is just the metadata in openfn.yaml, + // it's not super reliable + // Actually would it not be better to find the ACTUAL project and just + // reference that? this.activeProject = context.project; const projectsPath = path.join(workspacePath, this.config.dirs.projects); @@ -123,10 +130,13 @@ export class Workspace { ); } - async getCheckedOutProject() { + async getCheckedOutProject(alias?: string | null) { return await Project.from('fs', { root: this.root, config: this.config, + // The checked out project can't meaningfully be said to have an alias + // But we can force one if it makes sense from context + alias: alias ?? null, }).catch((e) => { if (e.code === 'ENOENT') return undefined; throw e; diff --git a/packages/project/src/index.ts b/packages/project/src/index.ts index 431036dd7..f80d48072 100644 --- a/packages/project/src/index.ts +++ b/packages/project/src/index.ts @@ -23,3 +23,5 @@ export { } from './util/version'; export { mapWorkflow } from './parse/from-app-state'; + +export type { MergeProjectOptions } from './merge/merge-project'; diff --git a/packages/project/src/merge/merge-project.ts b/packages/project/src/merge/merge-project.ts index b689bb57f..a80159a6b 100644 --- a/packages/project/src/merge/merge-project.ts +++ b/packages/project/src/merge/merge-project.ts @@ -16,21 +16,21 @@ export const REPLACE_MERGE = 'replace'; export class UnsafeMergeError extends Error {} export type MergeProjectOptions = { - workflowMappings: Record; // - removeUnmapped: boolean; - force: boolean; + workflowMappings?: Record; // + removeUnmapped?: boolean; + force?: boolean; /** * If mode is sandbox, basically only content will be merged and all metadata/settings/options/config is ignored * If mode is replace, all properties on the source will override the target (including UUIDs, name) */ - mode: typeof SANDBOX_MERGE | typeof REPLACE_MERGE; + mode?: typeof SANDBOX_MERGE | typeof REPLACE_MERGE; /** * If true, only workflows that have changed in the source * will be merged. */ - onlyUpdated: boolean; + onlyUpdated?: boolean; }; const defaultOptions: MergeProjectOptions = { @@ -54,7 +54,7 @@ const defaultOptions: MergeProjectOptions = { export function merge( source: Project, target: Project, - opts?: Partial + opts?: MergeProjectOptions ) { const options = defaultsDeep( opts, diff --git a/packages/project/src/parse/from-fs.ts b/packages/project/src/parse/from-fs.ts index 1382eecba..dd6bad57a 100644 --- a/packages/project/src/parse/from-fs.ts +++ b/packages/project/src/parse/from-fs.ts @@ -19,7 +19,7 @@ export type FromFsConfig = { root: string; config?: Partial; logger?: Logger; - alias?: string; + alias?: string | null; name?: string; };