Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/little-walls-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openfn/cli': minor
---

fetch: allow state files to be writtem to JSON with --format
79 changes: 66 additions & 13 deletions packages/cli/src/projects/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
loadAppAuthConfig,
getSerializePath,
} from './util';
import { writeFile } from 'node:fs/promises';

export type FetchOptions = Pick<
Opts,
Expand All @@ -23,6 +24,7 @@ export type FetchOptions = Pick<
| 'endpoint'
| 'env'
| 'force'
| 'format'
| 'log'
| 'logJson'
| 'snapshots'
Expand All @@ -45,6 +47,7 @@ const options = [
po.outputPath,
po.env,
po.workspace,
po.format,
];

const command: yargs.CommandModule<FetchOptions> = {
Expand All @@ -68,24 +71,72 @@ export default command;
const printProjectName = (project: Project) =>
`${project.qname} (${project.id})`;

export const handler = async (options: FetchOptions, logger: Logger) => {
const fetchV1 = async (options: FetchOptions, logger: Logger) => {
const workspacePath = options.workspace ?? process.cwd();
logger.debug('Using workspace at', workspacePath);

const workspace = new Workspace(workspacePath, logger, false);
const { outputPath } = options;
// TODO we may need to resolve an alias to a UUID and endpoint
const localProject = workspace.get(options.project!);
if (localProject) {
logger.debug(
`Resolved "${options.project}" to local project ${printProjectName(
localProject
)}`
);
} else {
logger.debug(
`Failed to resolve "${options.project}" to local project. Will send request to app anyway.`
);
}

const localTargetProject = await resolveOutputProject(
workspace,
options,
const config = loadAppAuthConfig(options, logger);

const { data } = await fetchProject(
options.endpoint ?? localProject?.openfn?.endpoint!,
config.apiKey,
localProject?.uuid ?? options.project!,
logger
);

const finalOutputPath = getSerializePath(
localProject,
options.workspace,
options.outputPath
);

logger.success(`Fetched project file to ${finalOutputPath}`);
await writeFile(finalOutputPath, JSON.stringify(data, null, 2));

// TODO should we return a Project or just the raw state?
return data;
};

export const handler = async (options: FetchOptions, logger: Logger) => {
if (options.format === 'state') {
return fetchV1(options, logger);
}
return fetchV2(options, logger);
};

export const fetchV2 = async (options: FetchOptions, logger: Logger) => {
const workspacePath = options.workspace ?? process.cwd();
logger.debug('Using workspace at', workspacePath);

const workspace = new Workspace(workspacePath, logger, false);
const { outputPath } = options;

const remoteProject = await fetchRemoteProject(workspace, options, logger);

ensureTargetCompatible(options, remoteProject, localTargetProject);
if (!options.force && options.format == 'state') {
const localTargetProject = await resolveOutputProject(
workspace,
options,
logger
);

// TODO should we use the local target project for output?
ensureTargetCompatible(options, remoteProject, localTargetProject);
}

// Work out where and how to serialize the project
const finalOutputPath = getSerializePath(
Expand All @@ -94,7 +145,7 @@ export const handler = async (options: FetchOptions, logger: Logger) => {
outputPath
);

let format: undefined | 'json' | 'yaml' = undefined;
let format: undefined | 'json' | 'yaml' | 'state' = options.format;
if (outputPath) {
// If the user gave us a path for output, we need to respect the format we've been given
const ext = path.extname(outputPath!).substring(1) as any;
Expand All @@ -112,12 +163,14 @@ export const handler = async (options: FetchOptions, logger: Logger) => {
// TODO report whether we've updated or not

// finally, write it!
await serialize(remoteProject, finalOutputPath!, format as any);

logger.success(
`Fetched project file to ${finalOutputPath}.${format ?? 'yaml'}`
const finalPathWithExt = await serialize(
remoteProject,
finalOutputPath!,
format as any
);

logger.success(`Fetched project file to ${finalPathWithExt}`);

return remoteProject;
};

Expand Down Expand Up @@ -193,7 +246,7 @@ export async function fetchRemoteProject(
localProject?.openfn?.uuid &&
localProject.openfn.uuid !== options.project
) {
// ifwe resolve the UUID to something other than what the user gave us,
// if we resolve the UUID to something other than what the user gave us,
// debug-log the UUID we're actually going to use
projectUUID = localProject.openfn.uuid as string;
logger.debug(
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/projects/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type Opts = BaseOpts & {
removeUnmapped?: boolean | undefined;
workflowMappings?: Record<string, string> | undefined;
project?: string;
format?: 'yaml' | 'json' | 'state';
};

// project specific options
Expand Down Expand Up @@ -36,6 +37,15 @@ export const dryRun: CLIOption = {
},
};

export const format: CLIOption = {
name: 'format',
yargs: {
hidden: true,
description:
'The format to save the project as - state, yaml or json. Use this to download raw state files.',
},
};

export const removeUnmapped: CLIOption = {
name: 'remove-unmapped',
yargs: {
Expand Down
16 changes: 10 additions & 6 deletions packages/cli/src/projects/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,30 @@ const ensureExt = (filePath: string, ext: string) => {
};

export const getSerializePath = (
project: Project,
workspacePath: string,
project?: Project,
workspacePath?: string,
outputPath?: string
) => {
const outputRoot = resolvePath(outputPath || workspacePath);
const outputRoot = resolvePath(outputPath || workspacePath || '.');
const projectsDir = project?.config.dirs.projects ?? '.projects';
return outputPath ?? `${outputRoot}/${projectsDir}/${project.qname}`;
return outputPath ?? `${outputRoot}/${projectsDir}/${project?.qname}`;
};

export const serialize = async (
project: Project,
outputPath: string,
formatOverride?: 'yaml' | 'json',
formatOverride?: 'yaml' | 'json' | 'state',
dryRun = false
) => {
const root = path.dirname(outputPath);
await mkdir(root, { recursive: true });

const format = formatOverride ?? project.config?.formats.project;
const output = project?.serialize('project', { format });

const output =
format === 'state'
? project?.serialize('state', { format: 'json' })
: project?.serialize('project', { format });

const maybeWriteFile = (filePath: string, output: string) => {
if (!dryRun) {
Expand Down