diff --git a/.changeset/little-walls-sneeze.md b/.changeset/little-walls-sneeze.md new file mode 100644 index 000000000..b9f014ded --- /dev/null +++ b/.changeset/little-walls-sneeze.md @@ -0,0 +1,5 @@ +--- +'@openfn/cli': minor +--- + +fetch: allow state files to be writtem to JSON with --format diff --git a/packages/cli/src/projects/fetch.ts b/packages/cli/src/projects/fetch.ts index d10c4626e..8ef164461 100644 --- a/packages/cli/src/projects/fetch.ts +++ b/packages/cli/src/projects/fetch.ts @@ -14,6 +14,7 @@ import { loadAppAuthConfig, getSerializePath, } from './util'; +import { writeFile } from 'node:fs/promises'; export type FetchOptions = Pick< Opts, @@ -23,6 +24,7 @@ export type FetchOptions = Pick< | 'endpoint' | 'env' | 'force' + | 'format' | 'log' | 'logJson' | 'snapshots' @@ -45,6 +47,7 @@ const options = [ po.outputPath, po.env, po.workspace, + po.format, ]; const command: yargs.CommandModule = { @@ -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( @@ -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; @@ -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; }; @@ -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( diff --git a/packages/cli/src/projects/options.ts b/packages/cli/src/projects/options.ts index dc35a74b8..5c32399a4 100644 --- a/packages/cli/src/projects/options.ts +++ b/packages/cli/src/projects/options.ts @@ -9,6 +9,7 @@ export type Opts = BaseOpts & { removeUnmapped?: boolean | undefined; workflowMappings?: Record | undefined; project?: string; + format?: 'yaml' | 'json' | 'state'; }; // project specific options @@ -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: { diff --git a/packages/cli/src/projects/util.ts b/packages/cli/src/projects/util.ts index e9202ba50..3e8817a08 100644 --- a/packages/cli/src/projects/util.ts +++ b/packages/cli/src/projects/util.ts @@ -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) {