Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions integration-tests/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# @openfn/integration-tests-cli

## 1.0.37

### Patch Changes

- Updated dependencies [760a984]
- Updated dependencies [760a984]
- @openfn/project@0.23.0
- @openfn/lightning-mock@2.5.7

## 1.0.36

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@openfn/integration-tests-cli",
"private": true,
"version": "1.0.36",
"version": "1.0.37",
"description": "CLI integration tests",
"author": "Open Function Group <admin@openfn.org>",
"license": "ISC",
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/cli/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from 'node:path';

export const getJSON = (pathToJson?: string) => {
if (!pathToJson) {
pathToJson = path.resolve('test/fixtures', 'output.json');
pathToJson = path.resolve('test/fixtures', 'tmp', 'output.json');
}
const data = readFileSync(pathToJson, 'utf8');
return JSON.parse(data);
Expand Down
6 changes: 3 additions & 3 deletions integration-tests/cli/test/execute-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ test.serial(
async (t) => {
const res = await run(t.title);
t.falsy(res.err);
const out = getJSON();
const out = getJSON('tmp/output.json');
t.deepEqual(out, {
data: {
result: 'love-some-suffix',
Expand All @@ -332,12 +332,12 @@ test.serial(
}
);

test.serial(
test.serial.only(
`openfn ${jobsPath}/globals-job.js --globals ${jobsPath}/globals-path-file.js -a common`,
async (t) => {
const res = await run(t.title);
t.falsy(res.err);
const out = getJSON();
const out = getJSON('tmp/output.json');
t.deepEqual(out, {
data: {
result: 'love-humble-suffix',
Expand Down
3 changes: 2 additions & 1 deletion integration-tests/cli/test/fixtures/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
output.json
output.json
tmp
15 changes: 15 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# @openfn/cli

## 1.44.0

### Minor Changes

- d91fa7c: Update the default output path used by `execute`. Output is now written to `tmp/output.json` relative to the thing that was executed (the workflow yaml or expression js)"
- d91fa7c: Default `execute` output to `tmp/output.json` relative to the workflow being run.
- 760a984: Support workflow aliases: rename a workflow's folder to use it as a shorter name

### Patch Changes

- b29758d: Fix an issue where deploying a project and passing a UUID directly, an error would be thrown if a local project file does not exist
- Updated dependencies [760a984]
- Updated dependencies [760a984]
- @openfn/project@0.23.0

## 1.43.1

### Patch Changes
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,21 @@ openfn deploy
- `-c, --config, --config-path` - path to the config file (defaults to `.config.json`)
- `--no-confirm` - skip the confirmation prompt

## Workflow Aliases

Workflow ids can get long. To give a workflow a shorter name, rename its folder in the workspace:

```bash
mv workflows/my-really-long-workflow-name workflows/wf
openfn wf
```

The folder name is the alias. It is local only: it's never deployed, and `pull` and `checkout` keep the folder in place.

Aliases are matched to workflows by id, so if a workflow is renamed in the app (which changes its id), the next `pull` writes it back to a folder named after the new id. Just rename the folder again.

An alias can't be the same as another workflow's id or alias. Clashing aliases are dropped with a warning, and the workflow goes back to a folder named after its id.

## Checking a Workflow's Version Hash

`openfn project version` prints the version hash used to detect drift between a local and remote workflow (see `@openfn/project`'s `src/util/version.ts`). It accepts three kinds of input:
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openfn/cli",
"version": "1.43.1",
"version": "1.44.0",
"description": "CLI devtools for the OpenFn toolchain",
"repository": {
"url": "https://github.com/OpenFn/kit"
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/execute/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import compile from '../compile/compile';
import { Logger, printDuration } from '../util/logger';
import loadState from '../util/load-state';
import validateAdaptors from '../util/validate-adaptors';
import loadPlan from '../util/load-plan';
import loadPlan, { DEFAULT_OUTPUT } from '../util/load-plan';
import assertPath from '../util/assert-path';
import { clearCache } from '../util/cache';
import fuzzyMatchStep from '../util/fuzzy-match-step';
Expand Down Expand Up @@ -175,6 +175,10 @@ const executeHandler = async (options: ExecuteOptions, logger: Logger) => {
try {
const result = await execute(finalPlan, state, options, logger);

// Last resort default if loadPlan didn't resolve one
if (!options.outputStdout) {
options.outputPath ??= DEFAULT_OUTPUT;
}
await serializeOutput(options, result, logger);
const duration = printDuration(new Date().getTime() - start);
if (result?.errors) {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,10 +463,12 @@ export const outputPath: CLIOption = {
} else {
if (opts.outputStdout) {
delete opts.outputPath;
} else {
} else if (opts.command !== 'execute') {
const base = getBaseDir(opts);
setDefaultValue(opts, 'outputPath', nodePath.join(base, 'output.json'));
}
// For execute, the default is resolved late: relative to the workflow
// in loadPlan, or tmp/output.json in the cwd in the execute handler
}
// remove the alias
delete (opts as { o?: string }).o;
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/projects/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,20 @@ export const handler = async (options: CheckoutOptions, logger?: Logger) => {
}
}

// Workflow aliases are just folder names, so carry them over from the
// checked out project to keep renamed folders in place
const incomingIds = new Set(switchProject.workflows.map((wf) => wf.id));
for (const wf of switchProject.workflows) {
const alias = localProject?.getWorkflow(wf.id)?.alias;
if (alias && incomingIds.has(alias)) {
logger?.warn(
`Dropping alias "${alias}" for workflow "${wf.id}": it clashes with another workflow`
);
} else {
wf.alias ??= alias;
}
}

// delete workflow dir before expanding project
if (options.clean) {
await rimraf(workspace.workflowsPath);
Expand Down
87 changes: 48 additions & 39 deletions packages/cli/src/projects/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { build, ensure } from '../util/command-builders';
import { printRichDiff } from './diff';
import ensureCheckout from './ensure-checkout';
import isUuid from '../util/is-uuid';
import { getCredentialsVisitor, remapCredentials } from './credentials-helpers';

import type { Provisioner } from '@openfn/lexicon/lightning';
Expand Down Expand Up @@ -192,6 +193,13 @@ export const channelsChanged = (local: Project, remote: Project) =>
toResourceChannels(remote.channels, remote.credentials)
);

// The remote project to deploy to. This is all we need to fetch it,
// so it does not have to exist in the local workspace
type DeployTarget = {
uuid: string;
endpoint: string;
};

export type SyncResult = {
merged: Project;
remoteProject: Project;
Expand All @@ -209,30 +217,31 @@ const syncProjects = async (
config: Required<AuthOptions>,
ws: Workspace,
localProject: Project,
trackedProject: Project, // the project we want to update
target: DeployTarget, // the remote project we want to update
logger: Logger,
skipLocallyChangedCheck = false
): Promise<SyncResult | null> => {
// First step, fetch the latest version and write
// First step, fetch the latest version (held in memory, not written to disk)
// The target may not be tracked locally at all: all we need is a uuid and endpoint
// this may throw!
let remoteProject: Project;
try {
logger.info('Fetching remote target ', printProjectName(trackedProject));
// TODO should we prefer endpoint over alias?
// maybe if it's explicitly passed?
const endpoint = trackedProject.openfn?.endpoint ?? config.endpoint;
logger.info(
'Fetching remote target ',
`${target.uuid} at ${target.endpoint}`
);
const { data } = await fetchProject(
endpoint,
target.endpoint,
config.apiKey,
trackedProject.uuid!,
target.uuid,
logger
);

remoteProject = await Project.from('state', data!, {
endpoint: endpoint,
endpoint: target.endpoint,
});

logger.info('Downloaded latest version of project at ', endpoint);
logger.info('Downloaded latest version of project at ', target.endpoint);
} catch (e) {
logger.error(e);
throw e;
Expand Down Expand Up @@ -426,7 +435,8 @@ export async function handler(options: DeployOptions, logger: Logger) {
// Track the remote we want to target
// If the user passed an explicit target, we need to use that
// Otherwise just sync with the local project's own tracked remote
let tracker;
let tracker: Project | null | undefined;
let targetUuid: string | undefined;
if (options.new) {
// reset all metadata
localProject.openfn = {
Expand All @@ -451,45 +461,44 @@ export async function handler(options: DeployOptions, logger: Logger) {
tracker = ws.getTrackedProject();
}

// A project loaded from a file already knows which remote it belongs
// to, so it can serve as its own deploy destination if there's no
// locally tracked copy of it
if (!tracker && filePath) {
logger.debug(
'No locally tracked project found: deploying to the remote named in the file'
);
tracker = localProject;
// If the target isn't tracked locally we can still deploy to it, as long
// as we know its uuid: we'll fetch it from the endpoint
// (an alias can't be resolved without a local copy)
if (tracker) {
targetUuid = tracker.uuid ?? undefined;
// the local copy belongs to the project we deployed at, not the one
// we deployed from
alias ??= tracker.alias ?? undefined;
} else if (targetIdentifier && isUuid(targetIdentifier)) {
targetUuid = targetIdentifier;
} else if (filePath && !targetIdentifier) {
// A project loaded from a file already knows which remote it belongs to
targetUuid = localProject.uuid ?? undefined;
}

if (!tracker) {
// Is this really an error? Unlikely to happen I think
console.log(
`ERROR: Failed to find tracked remote project ${
targetIdentifier ?? localProject.uuid!
} locally`
);
console.log('To deploy a new project, add --new to the command');
// TODO can we automate the fetch bit?
// If it's a UUID it should be ok?
console.log(
'You may need to fetch the project before you can safely deploy'
if (!targetUuid) {
throw new Error(
`Failed to find project ${
targetIdentifier ?? ''
} in the workspace. Pass a project UUID to deploy to a project that is not tracked locally, or add --new to create a new project`
);

throw new Error('Failed to find remote project locally');
}

// the local copy belongs to the project we deployed at, not the one
// we deployed from
alias ??= tracker.alias ?? undefined;
logger.debug(
tracker
? 'Deploying to locally tracked project'
: 'No locally tracked project found: fetching target from the remote'
);
}

// Choose the target endpoint we want to deploy to
// If the user explicity passed --endpoint, use that
// If the local project is tracking an endpoint, use that (90% of cases)
// Otherwise fallback to to the auto-loaded config (probably coming from env)
let endpoint: string =
// (an untracked file deploy falls back to the endpoint in the file)
const endpoint: string =
options.endpoint ??
tracker?.openfn?.endpoint ??
(targetUuid && !tracker ? localProject.openfn?.endpoint : undefined) ??
config.endpoint ??
DEFAULT_ENDPOINT;

Expand Down Expand Up @@ -527,7 +536,7 @@ export async function handler(options: DeployOptions, logger: Logger) {
config,
ws!,
localProject,
tracker!,
{ uuid: targetUuid!, endpoint },
logger,
// a file's own snapshot tells us nothing about the target
!!filePath
Expand Down
6 changes: 2 additions & 4 deletions packages/cli/src/projects/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getSerializePath,
} from './util';
import { writeFile } from 'node:fs/promises';
import isUuid from '../util/is-uuid';

export type FetchOptions = Pick<
Opts,
Expand Down Expand Up @@ -259,7 +260,7 @@ export async function fetchRemoteProject(
// A full UUID, endpoint and output path leave nothing to resolve locally,
// so don't trip over duplicate local copies
const localProject =
options.outputPath && options.endpoint && UUID_RE.test(options.project!)
options.outputPath && options.endpoint && isUuid(options.project)
? undefined
: workspace.get(options.project!);
if (
Expand Down Expand Up @@ -310,9 +311,6 @@ export async function fetchRemoteProject(
return project;
}

const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function ensureTargetCompatible(
options: FetchOptions,
remoteProject: Project,
Expand Down
15 changes: 12 additions & 3 deletions packages/cli/src/projects/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,28 @@ export const handler = async (options: ProjectListOptions, logger: Logger) => {
// project, so there's no need to abort if we can't ask which it is
workspace = await ensureCheckout(workspace, logger, { required: false });

// Workflow aliases are folder names, so only the checked out project has them
const local = await workspace.getCheckedOutProject();

logger.always(`Available openfn projects\n\n${workspace
.list()
.map((p) => describeProject(p, p === workspace.getTrackedProject()))
.map((p) => {
const active = p === workspace.getTrackedProject();
return describeProject(p, active, active ? local : undefined);
})
.join('\n\n')}
`);
};

function describeProject(project: Project, active = false) {
function describeProject(project: Project, active = false, local?: Project) {
// @ts-ignore
const uuid = project.openfn?.uuid;
return `${project.alias || '(no alias)'} | ${project.id} ${
active ? '(active)' : ''
}\n ${uuid || '<project-id>'}\n workflows:\n${project.workflows
.map((w) => ' - ' + w.id)
.map((w) => {
const alias = local?.workflows.find((l) => l.id === w.id)?.alias;
return ' - ' + w.id + (alias ? ` (${alias})` : '');
})
.join('\n')}`;
}
2 changes: 1 addition & 1 deletion packages/cli/src/projects/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const handler = async (options: VersionOptions, logger: Logger) => {

const activeProject = await workspace.getCheckedOutProject();
if (options.workflow) {
const workflow = activeProject?.getWorkflow(options.workflow);
const workflow = activeProject?.findWorkflow(options.workflow);
if (!workflow) {
logger.error(`No workflow found with id ${options.workflow}`);
return;
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/util/is-uuid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

const isUuid = (str?: string | null): boolean => !!str && UUID_RE.test(str);

export default isUuid;
Loading
Loading