Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createApp } from './app.ts';
import { registerWorkflowCommand } from './commands/workflow.ts';

const program = createApp();

registerWorkflowCommand(program);

program.parseAsync(process.argv).catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
Expand Down
95 changes: 95 additions & 0 deletions src/commands/workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Command } from 'commander';
import { getConfig } from '../config.ts';
import { runCommand } from '../utils/process.ts';
import * as output from '../utils/output.ts';

/**
* Registers the `workflow` (and `do` alias) command with the CLI program.
*
* @since TBD
*
* @param {Command} program - The Commander.js program instance.
*
* @returns {void}
*/
export function registerWorkflowCommand(program: Command): void {
program
.command('workflow <workflow>')
.alias('do')
.description('Run a command workflow.')
.option('--root <dir>', 'Run workflow commands in the given directory instead of the current working directory.')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how this flag worked in the PHP version as well. The description for it just wasn't clear.

.allowUnknownOption(true)
.action(
async (
workflowName: string,
options: { root?: string },
command: Command
) => {
await executeWorkflow(workflowName, options, command);
}
);
}

/**
* Executes a named workflow's commands sequentially, passing through extra arguments.
*
* @since TBD
*
* @param {string} workflowName - The name of the workflow to execute.
* @param {object} options - The options object.
* @param {string} [options.root] - The root directory for running commands.
* @param {Command} command - The Commander.js command instance (used to extract extra arguments).
*
* @returns {Promise<void>}
*/
async function executeWorkflow(
workflowName: string,
options: { root?: string },
command: Command
): Promise<void> {
const config = getConfig();
const workflows = config.getWorkflows();
const cwd = options.root ?? config.getWorkingDir();

const workflow = workflows.get(workflowName);
if (!workflow) {
output.error(`The workflow '${workflowName}' does not exist.`);
process.exit(1);
}

// Extract extra args passed after --
const extraArgs = command.args.slice(1); // First arg is the workflow name

output.log(`Running workflow: ${workflowName}`);

for (const step of workflow.commands) {
let cmd = step;
let bailOnFailure = true;

if (cmd.startsWith('@')) {
bailOnFailure = false;
cmd = cmd.slice(1);
}

// Append extra args
if (extraArgs.length > 0) {
cmd += ' ' + extraArgs.join(' ');
}

output.section(`> ${cmd}`);

const result = await runCommand(cmd, {
cwd,
});

if (result.exitCode !== 0) {
output.error(`[FAIL] Workflow step failed: ${cmd}`);
if (bailOnFailure) {
output.error('Exiting...');
process.exit(result.exitCode);
}
}
}

output.success('Workflow complete.');
}
141 changes: 141 additions & 0 deletions tests/commands/workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import path from 'node:path';
import {
runPup,
writePuprc,
getPuprc,
createTempProject,
cleanupTempProjects,
fixturesDir,
} from '../helpers/setup.js';

describe('workflow command', () => {
let projectDir: string;

beforeEach(() => {
projectDir = createTempProject();
writePuprc(getPuprc(), projectDir);
});

afterEach(() => {
cleanupTempProjects();
});

it('should run a workflow', async () => {
const puprc = getPuprc();
puprc.workflows = {
'my-workflow': ['echo "workflow step 1"', 'echo "workflow step 2"'],
};
writePuprc(puprc, projectDir);

const result = await runPup('workflow my-workflow', { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.output).toContain('workflow step 1');
expect(result.output).toContain('workflow step 2');
});

it('should support "do" alias', async () => {
const puprc = getPuprc();
puprc.workflows = {
'my-workflow': ['echo "workflow via do"'],
};
writePuprc(puprc, projectDir);

const result = await runPup('do my-workflow', { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.output).toContain('workflow via do');
});

it('should error when workflow does not exist', async () => {
const result = await runPup('workflow nonexistent', { cwd: projectDir });
expect(result.exitCode).not.toBe(0);
});

it('should pass extra args to workflow', async () => {
const scriptPath = path.resolve(fixturesDir, 'test-workflow-script.sh');
const puprc = getPuprc();
puprc.workflows = {
'my-workflow': [`bash ${scriptPath}`],
};
writePuprc(puprc, projectDir);

const result = await runPup('workflow my-workflow -- --option1 value1', { cwd: projectDir });
expect(result.exitCode).toBe(0);
});

it('should support multiple workflow commands', async () => {
const puprc = getPuprc();
puprc.workflows = {
'my-workflow': [
'echo "step 1"',
'echo "step 2"',
'echo "step 3"',
],
};
writePuprc(puprc, projectDir);

const result = await runPup('workflow my-workflow', { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.output).toContain('step 1');
expect(result.output).toContain('step 2');
expect(result.output).toContain('step 3');
});

it('should handle soft-fail commands in workflows', async () => {
const puprc = getPuprc();
puprc.workflows = {
'my-workflow': [
'@false',
'echo "still running"',
],
};
writePuprc(puprc, projectDir);

const result = await runPup('workflow my-workflow', { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.output).toContain('still running');
});

describe('--root flag', () => {
let rootDir: string;

beforeEach(() => {
rootDir = createTempProject();
});

it('should run workflow commands in the --root directory', async () => {
const puprc = getPuprc();
puprc.workflows = {
'pwd-workflow': ['pwd'],
};
writePuprc(puprc, projectDir);

const result = await runPup(`workflow pwd-workflow --root ${rootDir}`, { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.output).toContain(rootDir);
});

it('should work with the "do" alias and --root', async () => {
const puprc = getPuprc();
puprc.workflows = {
'my-workflow': ['pwd'],
};
writePuprc(puprc, projectDir);

const result = await runPup(`do my-workflow --root ${rootDir}`, { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.output).toContain(rootDir);
});

it('should pass extra args with --root', async () => {
const scriptPath = path.resolve(fixturesDir, 'test-workflow-script.sh');
const puprc = getPuprc();
puprc.workflows = {
'my-workflow': [`bash ${scriptPath}`],
};
writePuprc(puprc, projectDir);

const result = await runPup(`workflow my-workflow --root ${rootDir} -- --option1 value1`, { cwd: projectDir });
expect(result.exitCode).toBe(0);
});
});
});