diff --git a/.changeset/small-cases-buy.md b/.changeset/small-cases-buy.md new file mode 100644 index 000000000..a05e27a3e --- /dev/null +++ b/.changeset/small-cases-buy.md @@ -0,0 +1,5 @@ +--- +'@openfn/cli': patch +--- + +When running commands with v2 sync, if no checkout metadata is detected, prompt the user diff --git a/integration-tests/cli/.gitignore b/integration-tests/cli/.gitignore index ffdcec42c..f3bb1a002 100644 --- a/integration-tests/cli/.gitignore +++ b/integration-tests/cli/.gitignore @@ -1 +1,2 @@ -repo \ No newline at end of file +repo +.state.json diff --git a/integration-tests/cli/.state.json b/integration-tests/cli/.state.json deleted file mode 100644 index b9880f4ba..000000000 --- a/integration-tests/cli/.state.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "workflows": { - "my-workflow": { - "id": "702bf73f-fde1-44ca-87c5-c9e508ec2407", - "name": "My Workflow", - "jobs": { - "transform-data": { - "id": "43d6474d-df15-4ce6-a1a3-52e6400a36a9", - "name": "Transform data", - "adaptor": "@openfn/language-common@latest", - "body": "fn(s => s)", - "project_credential_id": "0f750fd9-b34c-4cf1-a6a9-a97cc18a2f4c" - } - }, - "triggers": { - "webhook": { - "id": "ca8358c9-b47b-4ad6-ba45-7a37384b81af", - "type": "webhook", - "enabled": true - } - }, - "edges": { - "webhook->transform-data": { - "id": "7026ceb7-f841-4b4b-a182-b2470384a1c2", - "condition_type": null, - "target_job_id": "43d6474d-df15-4ce6-a1a3-52e6400a36a9", - "enabled": true, - "source_trigger_id": "ca8358c9-b47b-4ad6-ba45-7a37384b81af" - } - }, - "lock_version": 1, - "inserted_at": "2026-10-05T14:49:28.664Z", - "updated_at": "2026-10-05T14:49:28.664Z", - "deleted_at": null, - "version_history": [ - "app:d9287d0b8d8c" - ] - } - }, - "id": "9351c38a-18ca-48ee-af7f-e4a479d92294", - "name": "My Project", - "project_credentials": { - "super@openfn.org|http1": { - "id": "0f750fd9-b34c-4cf1-a6a9-a97cc18a2f4c", - "name": "http1", - "owner": "super@openfn.org" - } - }, - "collections": {}, - "inserted_at": "2026-10-05T14:49:28.664Z", - "updated_at": "2026-10-05T14:49:28.664Z", - "channels": {} -} \ No newline at end of file diff --git a/packages/cli/src/projects/checkout.ts b/packages/cli/src/projects/checkout.ts index 10158530f..c22a7b375 100644 --- a/packages/cli/src/projects/checkout.ts +++ b/packages/cli/src/projects/checkout.ts @@ -164,11 +164,8 @@ export const handler = async (options: CheckoutOptions, logger?: Logger) => { }); if (isAdHoc) { // Don't touch the workspace or checkout metadata - for (const { path: configPath } of switchProject.generateConfig( - options.branch - )) { - delete files[configPath]; - } + delete files[switchProject.generateConfig().path]; + delete files[switchProject.generateCheckout(options.branch).path]; } for (const f in files) { if (files[f]) { diff --git a/packages/cli/src/projects/clean.ts b/packages/cli/src/projects/clean.ts index 152dd2573..9c9b63f97 100644 --- a/packages/cli/src/projects/clean.ts +++ b/packages/cli/src/projects/clean.ts @@ -4,6 +4,7 @@ import { rimraf } from 'rimraf'; import { build, ensure } from '../util/command-builders'; import { handler as checkout } from './checkout'; +import ensureCheckout from './ensure-checkout'; import type { Logger } from '../util/logger'; import * as o from '../options'; import * as po from './options'; @@ -29,9 +30,22 @@ export default command; export const handler = async (options: CleanOptions, logger: Logger) => { const workspacePath = options.workspace ?? process.cwd(); - const workspace = new Workspace(workspacePath, logger, true, { - branch: options.branch, - }); + + // Find out what's checked out before deleting anything + const workspace = await ensureCheckout( + new Workspace(workspacePath, logger, true, { + branch: options.branch, + }), + logger + ); + + // This has to be checked before deleting anything + const activeProject = workspace.activeProject; + if (!activeProject) { + throw new Error( + 'No active project found in workspace. Run `project pull` first.' + ); + } const skip = options.force || options.confirm === false; const doIt = await logger.confirm( @@ -44,13 +58,6 @@ export const handler = async (options: CleanOptions, logger: Logger) => { await rimraf(workspace.workflowsPath); - const activeProject = workspace.activeProject; - if (!activeProject) { - throw new Error( - 'No active project found in workspace. Run `project pull` first.' - ); - } - const projectId = String(activeProject.uuid ?? (activeProject as any).id); await checkout({ ...options, project: projectId, force: true }, logger); }; diff --git a/packages/cli/src/projects/deploy.ts b/packages/cli/src/projects/deploy.ts index ed1054f36..88c4fd8f3 100644 --- a/packages/cli/src/projects/deploy.ts +++ b/packages/cli/src/projects/deploy.ts @@ -18,12 +18,13 @@ import { fetchProject, serialize, getSerializePath, - updateForkedFrom, + writeCheckoutFile, findLocallyChangedWorkflows, AuthOptions, } from './util'; import { build, ensure } from '../util/command-builders'; import { printRichDiff } from './diff'; +import ensureCheckout from './ensure-checkout'; import { getCredentialsVisitor, remapCredentials } from './credentials-helpers'; import type { Provisioner } from '@openfn/lexicon/lightning'; @@ -404,6 +405,13 @@ export async function handler(options: DeployOptions, logger: Logger) { branch: options.branch, }); + // We only need to know what's checked out if we're going to write the + // checkout back after deploying, and if the user hasn't told us which + // project to deploy to + if (options.checkout !== false && !targetIdentifier) { + ws = await ensureCheckout(ws, logger); + } + const active = ws.getTrackedProject(); if (!alias && active?.alias) { @@ -617,25 +625,19 @@ export async function handler(options: DeployOptions, logger: Logger) { ); if (options.checkout !== false) { - updateForkedFrom(finalProject); + const workspacePath = options.workspace ?? process.cwd(); // Write the updated openfn.yaml and checkout file // TODO: allow us to suppress writing this stuff // (useful if posting from spec) - for (const configData of finalProject.generateConfig(options.branch)) { - const configPath = path.resolve( - options.workspace ?? process.cwd(), - configData.path - ); - await mkdir(path.dirname(configPath), { recursive: true }); - await writeFile(configPath, configData.content); - } + const configData = finalProject.generateConfig(); + const configPath = path.resolve(workspacePath, configData.path); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, configData.content); + await writeCheckoutFile(workspacePath, finalProject, options.branch); // Sync collections into resources.yaml (anything else in there is left alone) - const resourcesPath = path.resolve( - options.workspace ?? process.cwd(), - RESOURCES_FILE - ); + const resourcesPath = path.resolve(workspacePath, RESOURCES_FILE); const existingResources = await readFile(resourcesPath, 'utf8').catch( (e) => { if (e.code !== 'ENOENT') throw e; diff --git a/packages/cli/src/projects/ensure-checkout.ts b/packages/cli/src/projects/ensure-checkout.ts new file mode 100644 index 000000000..c14327665 --- /dev/null +++ b/packages/cli/src/projects/ensure-checkout.ts @@ -0,0 +1,125 @@ +import { select } from '@inquirer/prompts'; +import { Workspace, hasCheckoutMeta } from '@openfn/project'; +import type Project from '@openfn/project'; + +import abort from '../util/abort'; +import type { Logger } from '../util/logger'; +import { writeCheckoutFile } from './util'; + +// Resolves to the chosen project, or null if the user skipped the question +type Prompt = ( + projects: Project[], + defaultProject: Project +) => Promise; + +type EnsureCheckoutOptions = { + // Can we ask the user questions? Defaults to whether stdin is a terminal + interactive?: boolean; + prompt?: Prompt; + // If false, carry on without a checkout when we can't ask the user, rather + // than aborting (for commands that can work without one) + required?: boolean; +}; + +const name = (project: Project) => project.alias ?? project.id; + +// eg `staging (my-project | 1234-abcd)`. Either id may be missing +export const describeProject = (project: Project) => { + const ids = [project.id, project.openfn?.uuid].filter(Boolean).join(' | '); + return `${project.alias || '(no alias)'}${ids ? ` (${ids})` : ''}`; +}; + +const promptForProject: Prompt = (projects, defaultProject) => + select({ + message: + 'No checked out project found. Select the project which is currently tracked.', + choices: [ + ...projects.map((project) => ({ + name: describeProject(project), + value: project as Project | null, + description: project.openfn?.endpoint, + })), + { + name: 'Skip checkout', + value: null, + description: 'Continue without setting the checked out project', + }, + ], + default: defaultProject, + }); + +// Guess which project is checked out: one with an alias matching the git +// branch (or main, if there's no branch), else just the first. +// Pass undefined, not null or false, if there is no branch +export const getDefaultProject = (projects: Project[], branch = 'main') => + projects.find((p) => p.alias === branch) ?? projects[0]; + +/** + * Make sure the workspace knows which project is checked out. + * + * If there's no checkout metadata (say, after a fresh git clone, since it's + * not tracked in git), work out which project the files belong to and set + * up the checkout file. The files themselves are not touched. + * + * Returns a workspace that reflects the checkout. + */ +export default async ( + workspace: Workspace, + logger: Logger, + { + interactive = Boolean(process.stdin.isTTY), + prompt = promptForProject, + required = true, + }: EnsureCheckoutOptions = {} +) => { + if (!workspace.valid || hasCheckoutMeta(workspace.root, workspace.branch)) { + return workspace; + } + + const projects = workspace.list(); + if (!projects.length) { + // Nothing to choose from: let the command report that in its own way + return workspace; + } + + if (projects.length > 1 && !interactive && !required) { + logger.debug('No checked out project found: carrying on without one'); + return workspace; + } + + let project: Project | null = null; + if (projects.length === 1) { + project = projects[0]; + logger.info(`No checked out project found: using ${name(project)}`); + } else if (interactive) { + logger.info( + 'Failed to find a checkout.yaml file, which describes which project is currently checked out. This usually happens when pulling a project from git.' + ); + logger.info( + "No problem - just pick which project is checked out. This won't affect your working tree, it'll just update local metadata and tell the CLI which remote project instance to track\n." + ); + project = await prompt( + projects, + getDefaultProject(projects, workspace.branch || undefined) + ); + } else { + abort(logger, 'No checked out project found', { + details: `This workspace tracks several projects (${projects + .map(name) + .join(', ')}) and it's not known which one is checked out`, + fix: 'Run this command in an interactive terminal to choose one', + }); + } + + if (!project) { + logger.info('Skipped: no checked out project has been set'); + return workspace; + } + + await writeCheckoutFile(workspace.root, project, workspace.branch); + logger.success(`Set the checked out project to ${name(project)}`); + + return new Workspace(workspace.root, logger, true, { + branch: workspace.branch, + }); +}; diff --git a/packages/cli/src/projects/list.ts b/packages/cli/src/projects/list.ts index 629b25407..2b0576f88 100644 --- a/packages/cli/src/projects/list.ts +++ b/packages/cli/src/projects/list.ts @@ -8,6 +8,7 @@ import * as po from './options'; import type { Opts } from './options'; import abort from '../util/abort'; +import ensureCheckout from './ensure-checkout'; export type ProjectListOptions = Pick; @@ -28,7 +29,7 @@ export const handler = async (options: ProjectListOptions, logger: Logger) => { logger.info(' ', options.workspace); logger.break(); - const workspace = new Workspace(options.workspace!, undefined, true, { + let workspace = new Workspace(options.workspace!, undefined, true, { branch: options.branch, }); @@ -42,6 +43,10 @@ export const handler = async (options: ProjectListOptions, logger: Logger) => { }); } + // Listing works fine without a checkout: it just can't mark the active + // project, so there's no need to abort if we can't ask which it is + workspace = await ensureCheckout(workspace, logger, { required: false }); + logger.always(`Available openfn projects\n\n${workspace .list() .map((p) => describeProject(p, p === workspace.getTrackedProject())) diff --git a/packages/cli/src/projects/merge.ts b/packages/cli/src/projects/merge.ts index 90b0d3aec..08634ce06 100644 --- a/packages/cli/src/projects/merge.ts +++ b/packages/cli/src/projects/merge.ts @@ -10,6 +10,7 @@ import * as po from './options'; import type { Opts } from './options'; import { handler as checkout } from './checkout'; +import ensureCheckout from './ensure-checkout'; export type MergeOptions = Required< Pick< @@ -63,13 +64,16 @@ export default command; export const handler = async (options: MergeOptions, logger: Logger) => { const workspacePath = options.workspace; - const workspace = new Workspace(workspacePath, undefined, true, { + let workspace = new Workspace(workspacePath, undefined, true, { branch: options.branch, }); if (!workspace.valid) { logger.error('Command was run in an invalid openfn workspace'); return; } + if (!options.base) { + workspace = await ensureCheckout(workspace, logger); + } let targetProject: Project; if (options.base) { diff --git a/packages/cli/src/projects/pull.ts b/packages/cli/src/projects/pull.ts index a3d3a85a7..06d183ef2 100644 --- a/packages/cli/src/projects/pull.ts +++ b/packages/cli/src/projects/pull.ts @@ -4,6 +4,7 @@ import { Workspace } from '@openfn/project'; import { build, ensure, override } from '../util/command-builders'; import { handler as fetch } from './fetch'; import { handler as checkout } from './checkout'; +import ensureCheckout from './ensure-checkout'; import * as o from '../options'; import * as o2 from './options'; @@ -67,7 +68,7 @@ export const command: yargs.CommandModule = { export async function handler(options: PullOptions, logger: Logger) { options.workspace ??= process.cwd(); - ensureProjectId(options, logger); + await ensureProjectId(options, logger); await fetch(options, logger); logger.info(`Downloaded latest project version`); @@ -76,14 +77,17 @@ export async function handler(options: PullOptions, logger: Logger) { logger.success(`Checked out project locally`); } -const ensureProjectId = (options: any, logger?: Logger) => { +const ensureProjectId = async (options: any, logger: Logger) => { if (!options.project) { logger?.debug( 'No project ID specified: looking up checked out project in Workspace' ); - const ws = new Workspace(options.workspace, undefined, true, { - branch: options.branch, - }); + const ws = await ensureCheckout( + new Workspace(options.workspace, undefined, true, { + branch: options.branch, + }), + logger + ); if (ws.activeProject) { options.project = ws.activeProject.uuid; logger?.info( diff --git a/packages/cli/src/projects/util.ts b/packages/cli/src/projects/util.ts index 4e8b84d2a..a34e8d7d2 100644 --- a/packages/cli/src/projects/util.ts +++ b/packages/cli/src/projects/util.ts @@ -260,6 +260,23 @@ export const updateForkedFrom = (proj: Project) => { return proj; }; +// Set up the checkout file for a project without touching anything else +// (use this when the workflows are already on disk, like after a git clone) +export const writeCheckoutFile = async ( + workspacePath: string, + project: Project, + branch?: string | false | null +) => { + updateForkedFrom(project); + + const { path: checkoutPath, content } = project.generateCheckout(branch); + const filePath = path.resolve(workspacePath, checkoutPath); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + + return filePath; +}; + // 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 ( diff --git a/packages/cli/test/projects/clean.test.ts b/packages/cli/test/projects/clean.test.ts index f953c3244..1d0c646cb 100644 --- a/packages/cli/test/projects/clean.test.ts +++ b/packages/cli/test/projects/clean.test.ts @@ -115,3 +115,43 @@ test.serial( ); } ); + +test.serial( + 'does not delete anything if it is not known which project is checked out', + async (t) => { + // two tracked projects, but no checkout metadata anywhere + mock({ + '/ws/workflows/old-workflow': { 'old-job.js': 'fn(s => s)' }, + '/ws/openfn.yaml': '', + '/ws/.projects/main@app.openfn.org.yaml': projectStateFile, + '/ws/.projects/staging@app.openfn.org.yaml': projectStateFile, + }); + const exitCode = process.exitCode; + + await t.throwsAsync( + cleanHandler({ workspace: '/ws', force: true } as any, logger), + { message: 'No checked out project found' } + ); + process.exitCode = exitCode; + + t.true(fs.existsSync('/ws/workflows/old-workflow/old-job.js')); + } +); + +test.serial( + 'does not delete anything if there is no active project to restore', + async (t) => { + // a workspace with no projects and no checkout metadata at all + mock({ + '/ws/workflows/old-workflow': { 'old-job.js': 'fn(s => s)' }, + '/ws/openfn.yaml': '', + }); + + await t.throwsAsync( + cleanHandler({ workspace: '/ws', force: true } as any, logger), + { message: /No active project found/ } + ); + + t.true(fs.existsSync('/ws/workflows/old-workflow/old-job.js')); + } +); diff --git a/packages/cli/test/projects/deploy.test.ts b/packages/cli/test/projects/deploy.test.ts index f07fc0eb2..f0f545e10 100644 --- a/packages/cli/test/projects/deploy.test.ts +++ b/packages/cli/test/projects/deploy.test.ts @@ -30,6 +30,7 @@ import { myProject_v1_spec, } from './fixtures'; import { checkout } from '../../src/projects'; +import { rimraf } from 'rimraf'; let server: any; let strictServer: any; @@ -1361,3 +1362,140 @@ test('printRichDiff: should list changed channel fields', (t) => { t.truthy(logger._find('always', /one: changed/)); t.truthy(logger._find('always', /enabled: "true" -> "false"/)); }); + +const twoProjectsNoCheckout = () => + mockFs({ + '/ws/.projects/main@localhost.yaml': projectYaml, + '/ws/.projects/staging@localhost.yaml': projectYaml, + '/ws/openfn.yaml': '', + }); + +test.serial( + 'deploy: aborts if it is not known which project is checked out', + async (t) => { + twoProjectsNoCheckout(); + const exitCode = process.exitCode; + + await t.throwsAsync( + deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + confirm: false, + } as any, + logger + ), + { message: 'No checked out project found' } + ); + process.exitCode = exitCode; + } +); + +test.serial( + 'deploy: does not look for the checked out project with checkout: false', + async (t) => { + twoProjectsNoCheckout(); + // use a private logger: an absence check isn't safe on the shared one + const privateLogger = createMockLogger(undefined, { level: 'debug' }); + + // this deploy doesn't go anywhere, but it must not stop to ask + await deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + confirm: false, + checkout: false, + } as any, + privateLogger + ).catch(() => {}); + + t.falsy(privateLogger._find('error', /No checked out project found/)); + } +); + +test.serial( + 'deploy: does not look for the checked out project if there is an explicit target', + async (t) => { + twoProjectsNoCheckout(); + const privateLogger = createMockLogger(undefined, { level: 'debug' }); + + // this deploy doesn't go anywhere, but it must not stop to ask + await deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + confirm: false, + project: 'staging', + } as any, + privateLogger + ).catch(() => {}); + + t.falsy(privateLogger._find('error', /No checked out project found/)); + } +); + +test.serial( + 'deploy: merges the workspace into an explicit target on a fresh clone, without a checkout', + async (t) => { + await setup(projectYaml); + + // simulate a fresh git clone: the workflows are there, nothing is checked + // out, and there's more than one tracked project + // (fs.rmSync is a no-op under mock-fs, hence rimraf) + await rimraf('/ws/.openfn'); + t.false(fs.existsSync('/ws/.openfn')); + fs.writeFileSync('/ws/.projects/other@localhost.yaml', projectYaml); + await writeFile('/ws/workflows/my-workflow/transform-data.js', 'log()'); + + await deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + project: 'main', + checkout: false, + force: true, + confirm: false, + } as any, + logger + ); + + t.truthy(logger._find('success', /Updated project at/)); + t.is( + server.state.projects[UUID].workflows['my-workflow'].jobs[ + 'transform-data' + ].body, + 'log()' + ); + // deploying doesn't set up a checkout + t.false(fs.existsSync('/ws/.openfn')); + } +); + +test.serial( + 'deploy: deploys a project file on a fresh clone, without a checkout', + async (t) => { + mockFs({ + '/ws/project.yaml': myProject_spec, + '/ws/openfn.yaml': '', + '/ws/.projects/main@localhost.yaml': projectYaml, + '/ws/.projects/other@localhost.yaml': projectYaml, + }); + + await deploy( + { + endpoint: ENDPOINT, + apiKey: 'test-api-key', + workspace: '/ws', + project: '/ws/project.yaml', + } as any, + logger + ); + + t.truthy(logger._find('success', /Created new project at/)); + t.is(Object.keys(server.state.projects).length, 2); + } +); diff --git a/packages/cli/test/projects/ensure-checkout.test.ts b/packages/cli/test/projects/ensure-checkout.test.ts new file mode 100644 index 000000000..e9ead3df6 --- /dev/null +++ b/packages/cli/test/projects/ensure-checkout.test.ts @@ -0,0 +1,300 @@ +import test from 'ava'; +import fs from 'node:fs'; +import mock from 'mock-fs'; +import { createMockLogger } from '@openfn/logger'; +import Project, { Workspace, yamlToJson } from '@openfn/project'; + +import ensureCheckout, { + describeProject, + getDefaultProject, +} from '../../src/projects/ensure-checkout'; + +const logger = createMockLogger(undefined, { level: 'debug' }); + +test.afterEach(() => { + mock.restore(); + logger._reset(); +}); + +const projectFile = (id: string) => + new Project({ + id, + name: id, + openfn: { uuid: `uuid-${id}`, endpoint: 'https://app.openfn.org' }, + workflows: [{ id: 'wf', name: 'wf', history: ['abc'], steps: [] }], + }).serialize('project', { format: 'yaml' }) as string; + +const setup = (ids = ['main', 'staging'], extra = {}) => + mock({ + '/ws/openfn.yaml': '', + '/ws/workflows/wf/wf.yaml': 'id: wf', + ...Object.fromEntries( + ids.map((id) => [ + `/ws/.projects/${id}@app.openfn.org.yaml`, + projectFile(id), + ]) + ), + ...extra, + }); + +const readCheckout = (path = '/ws/.openfn/checkout.yaml') => + yamlToJson(fs.readFileSync(path, 'utf8')); + +// A prompt that records what it was asked and picks a project by alias +const pickPrompt = (alias: string) => { + const calls: any[] = []; + const prompt = async (projects: Project[], defaultProject: Project) => { + calls.push({ projects, defaultProject }); + return projects.find((p) => p.alias === alias)!; + }; + return { prompt, calls }; +}; + +const neverPrompt = async () => { + throw new Error('should not have prompted'); +}; + +test.serial('does nothing if there is already checkout metadata', async (t) => { + setup(['main', 'staging'], { '/ws/.openfn/checkout.yaml': 'id: main' }); + const workspace = new Workspace('/ws', logger); + + const result = await ensureCheckout(workspace, logger, { + interactive: true, + prompt: neverPrompt, + }); + + t.is(result, workspace); + t.is(readCheckout().id, 'main'); +}); + +test.serial('does nothing if there are no tracked projects', async (t) => { + setup([]); + const workspace = new Workspace('/ws', logger, false); + + const result = await ensureCheckout(workspace, logger, { + interactive: true, + prompt: neverPrompt, + }); + + t.is(result, workspace); + t.false(fs.existsSync('/ws/.openfn')); +}); + +test.serial('uses the only tracked project without prompting', async (t) => { + setup(['main']); + + const result = await ensureCheckout(new Workspace('/ws', logger), logger, { + interactive: true, + prompt: neverPrompt, + }); + + t.is(readCheckout().uuid, 'uuid-main'); + t.is(result.activeProject?.uuid, 'uuid-main'); +}); + +test.serial('prompts if there are several tracked projects', async (t) => { + setup(); + const { prompt, calls } = pickPrompt('staging'); + + const result = await ensureCheckout(new Workspace('/ws', logger), logger, { + interactive: true, + prompt, + }); + + // explain why we're asking + t.truthy(logger._find('info', /Failed to find a checkout.yaml file/)); + t.truthy(logger._find('info', /just pick which project is checked out/)); + + t.is(calls.length, 1); + t.deepEqual(calls[0].projects.map((p: Project) => p.alias).sort(), [ + 'main', + 'staging', + ]); + + // the checkout file has been set up, and the workspace knows about it + t.is(readCheckout().uuid, 'uuid-staging'); + t.is(result.activeProject?.uuid, 'uuid-staging'); +}); + +test.serial( + 'does not set anything up if the user skips the prompt', + async (t) => { + setup(); + const workspace = new Workspace('/ws', logger); + + const result = await ensureCheckout(workspace, logger, { + interactive: true, + prompt: async () => null, + }); + + t.is(result, workspace); + t.false(fs.existsSync('/ws/.openfn')); + t.truthy(logger._find('info', /Skipped/)); + } +); + +test.serial( + 'does not touch the workflows when setting up the checkout', + async (t) => { + setup(); + const { prompt } = pickPrompt('main'); + + await ensureCheckout(new Workspace('/ws', logger), logger, { + interactive: true, + prompt, + }); + + t.deepEqual(fs.readdirSync('/ws').sort(), [ + '.openfn', + '.projects', + 'openfn.yaml', + 'workflows', + ]); + t.is(fs.readFileSync('/ws/workflows/wf/wf.yaml', 'utf8'), 'id: wf'); + t.is(fs.readFileSync('/ws/openfn.yaml', 'utf8'), ''); + } +); + +test.serial('suggests the project matching the git branch', async (t) => { + setup(['main', 'staging']); + const { prompt, calls } = pickPrompt('main'); + + await ensureCheckout( + new Workspace('/ws', logger, true, { branch: 'staging' }), + logger, + { interactive: true, prompt } + ); + + t.is(calls[0].defaultProject.alias, 'staging'); +}); + +test.serial( + 'suggests main if there is no git branch, even if it is not first', + async (t) => { + // projects are listed alphabetically, so main is not first here + setup(['alpha', 'main']); + const { prompt, calls } = pickPrompt('alpha'); + + await ensureCheckout( + new Workspace('/ws', logger, true, { branch: false }), + logger, + { interactive: true, prompt } + ); + + t.is(calls[0].defaultProject.alias, 'main'); + } +); + +test.serial('writes the checkout file for the branch', async (t) => { + setup(); + const { prompt } = pickPrompt('staging'); + + await ensureCheckout( + new Workspace('/ws', logger, true, { branch: 'dev' }), + logger, + { interactive: true, prompt } + ); + + t.is( + readCheckout('/ws/.openfn/branches/dev/checkout.yaml').uuid, + 'uuid-staging' + ); + t.false(fs.existsSync('/ws/.openfn/checkout.yaml')); +}); + +test.serial( + 'aborts without prompting if several projects and not interactive', + async (t) => { + setup(); + const exitCode = process.exitCode; + + await t.throwsAsync( + ensureCheckout(new Workspace('/ws', logger), logger, { + interactive: false, + prompt: neverPrompt, + }), + { message: 'No checked out project found' } + ); + + // abort() flags the process as failed: don't let that leak out of the test + process.exitCode = exitCode; + + t.false(fs.existsSync('/ws/.openfn')); + t.truthy(logger._find('error', /No checked out project found/)); + } +); + +test.serial( + 'carries on without a checkout if not interactive and not required', + async (t) => { + setup(); + const workspace = new Workspace('/ws', logger); + + const result = await ensureCheckout(workspace, logger, { + interactive: false, + required: false, + prompt: neverPrompt, + }); + + t.is(result, workspace); + t.false(fs.existsSync('/ws/.openfn')); + } +); + +test.serial('still prompts if interactive, even if not required', async (t) => { + setup(); + const { prompt, calls } = pickPrompt('staging'); + + const result = await ensureCheckout(new Workspace('/ws', logger), logger, { + interactive: true, + required: false, + prompt, + }); + + t.is(calls.length, 1); + t.is(result.activeProject?.uuid, 'uuid-staging'); +}); + +const projects = (aliases: string[]) => + aliases.map((alias) => ({ alias })) as unknown as Project[]; + +test('getDefaultProject: prefers the alias matching the branch', (t) => { + const result = getDefaultProject(projects(['main', 'staging']), 'staging'); + t.is(result.alias, 'staging'); +}); + +test('getDefaultProject: defaults to main if there is no branch', (t) => { + t.is(getDefaultProject(projects(['a', 'main', 'b'])).alias, 'main'); +}); + +test('getDefaultProject: falls back to the first project', (t) => { + t.is(getDefaultProject(projects(['a', 'b']), 'feature').alias, 'a'); +}); + +const described = (props: object) => describeProject(props as Project); + +test('describeProject: alias with id and uuid', (t) => { + t.is( + described({ alias: 'staging', id: 'my-project', openfn: { uuid: 'abcd' } }), + 'staging (my-project | abcd)' + ); +}); + +test('describeProject: copes with a missing uuid or id', (t) => { + t.is( + described({ alias: 'staging', id: 'my-project' }), + 'staging (my-project)' + ); + t.is( + described({ alias: 'staging', openfn: { uuid: 'abcd' } }), + 'staging (abcd)' + ); +}); + +test('describeProject: just the alias if there is no id or uuid', (t) => { + t.is(described({ alias: 'staging' }), 'staging'); +}); + +test('describeProject: copes with a missing alias', (t) => { + t.is(described({ id: 'my-project' }), '(no alias) (my-project)'); +}); diff --git a/packages/cli/test/projects/list.test.ts b/packages/cli/test/projects/list.test.ts index 0891e5038..aab504a98 100644 --- a/packages/cli/test/projects/list.test.ts +++ b/packages/cli/test/projects/list.test.ts @@ -4,8 +4,14 @@ import { createMockLogger } from '@openfn/logger'; import mock from 'mock-fs'; import { jsonToYaml } from '@openfn/project'; +// a workspace with several projects but no checkout metadata +const noCheckoutProject = jsonToYaml({ id: 'my-project', workflows: [] }); + mock({ 'no-ws/': { 'some.yaml': 'name: smth' }, + '/no-checkout/openfn.yaml': '', + '/no-checkout/.projects/main@app.openfn.org.yaml': noCheckoutProject, + '/no-checkout/.projects/staging@app.openfn.org.yaml': noCheckoutProject, '/ws/openfn.yaml': jsonToYaml({ project: { id: 'my-project', @@ -172,3 +178,23 @@ staging | my-project message as string ); }); + +test.serial( + 'lists projects without a checkout, without prompting or aborting', + async (t) => { + const privateLogger = createMockLogger('', { level: 'debug' }); + + await list( + { command: 'projects', workspace: '/no-checkout' }, + privateLogger + ); + + const { message } = privateLogger._find( + 'always', + /available openfn projects/i + ); + t.regex(message as string, /^main \|/m); + t.regex(message as string, /^staging \|/m); + t.notRegex(message as string, /\(active\)/); + } +); diff --git a/packages/cli/test/projects/util.test.ts b/packages/cli/test/projects/util.test.ts index b4486ee17..b5a7fb44e 100644 --- a/packages/cli/test/projects/util.test.ts +++ b/packages/cli/test/projects/util.test.ts @@ -1,10 +1,17 @@ import test from 'ava'; -import Project, { generateWorkflow } from '@openfn/project'; +import fs from 'node:fs'; +import mock from 'mock-fs'; +import Project, { generateWorkflow, yamlToJson } from '@openfn/project'; import { findLocallyChangedWorkflows, tidyWorkflowDir, + writeCheckoutFile, } from '../../src/projects/util'; +test.afterEach(() => { + mock.restore(); +}); + test('tidyWorkflowDir: removes workflows that no longer exist', async (t) => { const currentProject = new Project({ name: 'current', @@ -271,3 +278,65 @@ test('findLocallyChangedWorkflows: detect 1 locally removed workflow', async (t) const changed = await findLocallyChangedWorkflows(workspace, project); t.deepEqual(changed, ['b']); }); + +const trackedProject = () => + new Project({ + id: 'my-project', + name: 'My Project', + openfn: { uuid: 'abcd', endpoint: 'https://app.openfn.org' }, + workflows: [ + { id: 'wf', name: 'wf', history: ['old', 'latest'], steps: [] }, + ], + }); + +test.serial( + 'writeCheckoutFile: writes the project meta and forked_from to the checkout file', + async (t) => { + mock({ '/ws': {} }); + + const filePath = await writeCheckoutFile('/ws', trackedProject()); + + t.is(filePath, '/ws/.openfn/checkout.yaml'); + t.deepEqual(yamlToJson(fs.readFileSync(filePath, 'utf8')), { + endpoint: 'https://app.openfn.org', + forked_from: { wf: 'latest' }, + id: 'my-project', + name: 'My Project', + uuid: 'abcd', + }); + } +); + +test.serial( + 'writeCheckoutFile: writes to the checkout file for a branch', + async (t) => { + mock({ '/ws': {} }); + + const filePath = await writeCheckoutFile('/ws', trackedProject(), 'dev'); + + t.is(filePath, '/ws/.openfn/branches/dev/checkout.yaml'); + t.true(fs.existsSync(filePath)); + t.false(fs.existsSync('/ws/.openfn/checkout.yaml')); + } +); + +test.serial( + 'writeCheckoutFile: does not touch the workspace config or workflows', + async (t) => { + mock({ + '/ws/openfn.yaml': 'credentials: creds.yaml', + '/ws/workflows/wf/wf.yaml': 'id: wf', + }); + + await writeCheckoutFile('/ws', trackedProject()); + + t.deepEqual(fs.readdirSync('/ws').sort(), [ + '.openfn', + 'openfn.yaml', + 'workflows', + ]); + t.is(fs.readFileSync('/ws/openfn.yaml', 'utf8'), 'credentials: creds.yaml'); + t.is(fs.readFileSync('/ws/workflows/wf/wf.yaml', 'utf8'), 'id: wf'); + t.deepEqual(fs.readdirSync('/ws/workflows/wf'), ['wf.yaml']); + } +); diff --git a/packages/project/src/Project.ts b/packages/project/src/Project.ts index 9a76a95ba..b81136b31 100644 --- a/packages/project/src/Project.ts +++ b/packages/project/src/Project.ts @@ -305,11 +305,18 @@ export class Project { } /** - * Generates the contents of the openfn.yaml and checkout files, - * plus their file paths + * Generates the contents of the openfn.yaml file, + * plus its file path */ - generateConfig(branch?: string | false | null) { - return [extractConfig(this), extractCheckout(this, branch)]; + generateConfig() { + return extractConfig(this); + } + + /** + * Generates the contents of the checkout file, plus its file path + */ + generateCheckout(branch?: string | false | null) { + return extractCheckout(this, branch); } /** diff --git a/packages/project/src/index.ts b/packages/project/src/index.ts index 44b1d428a..f7a87bf1c 100644 --- a/packages/project/src/index.ts +++ b/packages/project/src/index.ts @@ -18,6 +18,7 @@ export type { } from './util/project-diff'; export { RESOURCES_FILE } from './util/resources'; +export { hasCheckoutMeta } from './util/config'; export { generateStepDiff, generateEdgeDiff } from './util/workflow-diff'; export type { diff --git a/packages/project/src/util/config.ts b/packages/project/src/util/config.ts index a3225906f..77bbf3a78 100644 --- a/packages/project/src/util/config.ts +++ b/packages/project/src/util/config.ts @@ -181,3 +181,22 @@ export const findWorkspaceFile = (dir: string = '.') => { } return { content, type }; }; + +// Does this workspace know which project is checked out? +// True if there's checkout metadata in the checkout file, or in a legacy +// project block in openfn.yaml. An empty checkout file doesn't count +export const hasCheckoutMeta = ( + root: string = '.', + branch?: string | false | null +) => { + let legacyProject: l.ProjectMeta | undefined; + try { + const { type, content } = findWorkspaceFile(root); + legacyProject = loadWorkspaceFile(content, type as any).project; + } catch (e) { + // No workspace file: there can't be a legacy project block + } + + const meta = loadCheckoutFile(root, branch, legacyProject); + return !!meta && Object.keys(meta).length > 0; +}; diff --git a/packages/project/test/util/config.test.ts b/packages/project/test/util/config.test.ts index f0624ff97..6866e406f 100644 --- a/packages/project/test/util/config.test.ts +++ b/packages/project/test/util/config.test.ts @@ -3,6 +3,7 @@ import { extractCheckout, extractConfig, getCheckoutPath, + hasCheckoutMeta, findWorkspaceFile, loadWorkspaceFile, } from '../../src/util/config'; @@ -276,3 +277,54 @@ test('include project name', (t) => { }); test.todo('include parent project name'); + +test('hasCheckoutMeta: false if there is no workspace at all', (t) => { + mock({ '/ws': {} }); + + t.false(hasCheckoutMeta('/ws')); +}); + +test('hasCheckoutMeta: false if there is a workspace but no checkout file', (t) => { + mock({ '/ws/openfn.yaml': 'credentials: creds.yaml' }); + + t.false(hasCheckoutMeta('/ws')); +}); + +test('hasCheckoutMeta: true if there is a checkout file', (t) => { + mock({ + '/ws/openfn.yaml': 'credentials: creds.yaml', + '/ws/.openfn/checkout.yaml': 'id: my-project', + }); + + t.true(hasCheckoutMeta('/ws')); +}); + +test('hasCheckoutMeta: false if the checkout file is empty', (t) => { + mock({ + '/ws/openfn.yaml': 'credentials: creds.yaml', + '/ws/.openfn/checkout.yaml': '', + }); + + t.false(hasCheckoutMeta('/ws')); +}); + +test('hasCheckoutMeta: true if there is a legacy project block in openfn.yaml', (t) => { + mock({ + '/ws/openfn.yaml': + 'project:\n id: my-project\nworkspace:\n credentials: creds.yaml', + }); + + t.true(hasCheckoutMeta('/ws')); +}); + +test('hasCheckoutMeta: looks for the checkout file for the given branch', (t) => { + mock({ + '/ws/openfn.yaml': 'credentials: creds.yaml', + '/ws/.openfn/branches/dev/checkout.yaml': 'id: my-project', + }); + + t.true(hasCheckoutMeta('/ws', 'dev')); + // other branches, and no branch, don't have any + t.false(hasCheckoutMeta('/ws', 'main')); + t.false(hasCheckoutMeta('/ws')); +});