Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ This will start the interactive mode, which will guide you through:
- Deleting environments (except protected ones)
- Generating login links for environments
- Clearing Drupal caches
- Deploying branches to environments
- Viewing GitHub PR links for PR environments

## Features
Expand All @@ -58,6 +59,8 @@ This will start the interactive mode, which will guide you through:
- GitHub PR integration for PR environments
- One-click login link generation for Drupal environments
- Quick Drupal cache clearing for any environment
- Branch deployment with autocomplete selection
- SSH key configuration for Lagoon authentication
- Comprehensive logging of all operations
- Extensible architecture for adding new commands

Expand Down Expand Up @@ -92,6 +95,17 @@ The CLI can clear Drupal caches on any environment:
- Executes the `drush cr` command on the selected environment
- Shows the command output in the terminal

## Branch Deployment

The CLI can deploy any branch from a project's git repository:
- Automatically fetches and lists all available branches with autocomplete search
- Prioritizes main branches (main, master, develop) at the top of the list
- Initiates deployment of the selected branch to the Lagoon environment
- Provides real-time feedback about the deployment status
- Validates branch names to prevent command injection

Note: Branch deployment is asynchronous and may take several minutes to complete. You can check the status in the Lagoon UI.

## Logging

All operations performed by the CLI are logged to daily log files in the `.logs` directory:
Expand Down
2 changes: 1 addition & 1 deletion index.js → index.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

import { program } from 'commander';
import { startInteractiveMode } from './src/interactive';
import { startInteractiveMode } from './src/interactive.mjs';

program
.name('lagoon-wrapper')
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"eslint-plugin-n": "^17.17.0",
"globals": "^16.1.0",
"inquirer": "^9.2.12",
"inquirer-autocomplete-prompt": "^3.0.1",
"js-yaml": "^4.1.0",
"ora": "^7.0.1"
}
Expand Down
142 changes: 141 additions & 1 deletion src/interactive.js → src/interactive.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,26 @@ import {
generateLoginLink,
clearDrupalCache,
gitUrlToGithubUrl,
extractPrNumber
extractPrNumber,
getGitBranches,
deployBranch
} from './lagoon-api';
import { logAction } from './logger';
import { configureSshKey } from './lagoon-ssh-key-configurator';

// Register the autocomplete prompt with inquirer
try {
const autocompletePrompt = (await import('inquirer-autocomplete-prompt')).default;
inquirer.registerPrompt('autocomplete', autocompletePrompt);
} catch (error) {
console.log(chalk.yellow('Autocomplete prompt not available, falling back to standard list selection.'));
}

/**
* Launches the interactive Lagoon CLI wrapper, allowing users to manage projects and environments through a guided command-line interface.
*
* Presents menus for selecting Lagoon instances and projects, and provides options to list environments or users, delete environments, generate login links, clear Drupal cache, configure SSH keys, and change selections. Handles errors gracefully and logs major actions throughout the session.
*/
export async function startInteractiveMode() {
console.log(chalk.green('Welcome to the Lagoon CLI Wrapper!'));
logAction('Application Start', 'N/A', 'Interactive mode started');
Expand Down Expand Up @@ -66,6 +81,9 @@ export async function startInteractiveMode() {
case 'clearCache':
await clearCacheFlow(currentInstance, currentProject, githubBaseUrl);
break;
case 'deployBranch':
await deployBranchFlow(currentInstance, currentProject, currentProjectDetails);
break;
case 'configureUserSshKey':
await configureSshKey(currentInstance, currentProject);
break;
Expand Down Expand Up @@ -152,6 +170,13 @@ async function selectProjectWithDetails(instance) {
};
}

/**
* Displays the main menu for the interactive CLI and prompts the user to select an action.
*
* @param {string} instance - The name of the currently selected Lagoon instance.
* @param {string} project - The name of the currently selected project.
* @returns {Promise<string>} The action selected by the user.
*/
async function showMainMenu(instance, project) {
console.log(chalk.blue(`\nCurrent Instance: ${chalk.bold(instance)}`));
console.log(chalk.blue(`Current Project: ${chalk.bold(project)}\n`));
Expand All @@ -167,6 +192,7 @@ async function showMainMenu(instance, project) {
{ name: 'Delete Environment', value: 'deleteEnvironment' },
{ name: 'Generate Login Link', value: 'generateLoginLink' },
{ name: 'Clear Drupal Cache', value: 'clearCache' },
{ name: 'Deploy Branch', value: 'deployBranch' },
{ name: 'Change Project', value: 'changeProject' },
{ name: 'Change Instance', value: 'changeInstance' },
{ name: 'Configure User SSH Key', value: 'configureUserSshKey' },
Expand Down Expand Up @@ -440,3 +466,117 @@ async function clearCacheFlow(instance, project, githubBaseUrl) {
}
]);
}

async function deployBranchFlow(instance, project, projectDetails) {
// Check if project has a git URL
if (!projectDetails || !projectDetails.giturl) {
console.log(chalk.yellow('\nThis project does not have a valid Git URL configured.'));
await inquirer.prompt([
{
type: 'input',
name: 'continue',
message: 'Press Enter to continue...'
}
]);
return;
}

console.log(chalk.blue(`\nFetching branches from ${chalk.bold(projectDetails.giturl)}...`));

const spinner = ora('Loading branches...').start();
try {
// Get branches from the git repository
const branches = await getGitBranches(projectDetails.giturl);
spinner.succeed(`Found ${branches.length} branches.`);

if (branches.length === 0) {
console.log(chalk.yellow('\nNo branches found in the git repository.'));
await inquirer.prompt([
{
type: 'input',
name: 'continue',
message: 'Press Enter to continue...'
}
]);
return;
}

// Sort the branches to put main/master/develop first, then the rest alphabetically
const sortedBranches = branches.sort((a, b) => {
const priority = ['main', 'master', 'develop'];
const aIndex = priority.indexOf(a);
const bIndex = priority.indexOf(b);

if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
} else if (aIndex !== -1) {
return -1;
} else if (bIndex !== -1) {
return 1;
} else {
return a.localeCompare(b);
}
});

// Allow user to select a branch using autocomplete
const { selectedBranch } = await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedBranch',
message: 'Select a branch to deploy:',
source: (answersSoFar, input = '') => {
return Promise.resolve(
sortedBranches.filter(branch =>
!input || branch.toLowerCase().includes(input.toLowerCase())
)
);
}
},
{
type: 'list',
name: 'selectedBranch',
message: 'Select a branch to deploy:',
choices: sortedBranches
}
]);

// Confirm deployment
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to deploy branch ${chalk.bold(selectedBranch)} to project ${chalk.bold(project)}?`,
default: false
}
]);

if (!confirm) {
console.log(chalk.yellow('\nDeployment cancelled.'));
return;
}

// Deploy the branch
const spinner2 = ora(`Deploying branch ${selectedBranch}...`).start();
try {
const result = await deployBranch(instance, project, selectedBranch);
spinner2.succeed('Deployment initiated successfully.');

console.log(chalk.green('\nDeployment Status:'));
console.log(chalk.cyan(result.message || 'Branch deployment initiated.'));
console.log(chalk.blue('\nNote: The deployment process runs asynchronously and may take several minutes to complete.'));
console.log(chalk.blue('You can check the status of the deployment in the Lagoon UI.'));
} catch (error) {
spinner2.fail(`Failed to deploy branch: ${error.message}`);
}
} catch (error) {
spinner.fail(`Failed to fetch branches: ${error.message}`);
}

await inquirer.prompt([
{
type: 'input',
name: 'continue',
message: 'Press Enter to continue...'
}
]);
}
81 changes: 78 additions & 3 deletions src/lagoon-api.js → src/lagoon-api.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import chalk from 'chalk';
import { logAction, logError } from './logger';
import { logAction, logError } from './logger.mjs';

const execAsync = promisify(exec);

Expand Down Expand Up @@ -114,7 +114,18 @@ export async function getUsers(instance, project) {
}
}

// Delete an environment
/**
* Deletes a Lagoon environment unless it is protected.
*
* Throws an error if the environment is protected (such as 'production', 'master', 'develop', or names starting with 'project/'). Executes the Lagoon CLI to delete the specified environment and returns true if successful.
*
* @param {string} instance - The Lagoon instance name.
* @param {string} project - The project name.
* @param {string} environment - The environment name to delete.
* @returns {boolean} True if the environment was deleted successfully.
*
* @throws {Error} If the environment is protected or if the deletion fails.
*/
export async function deleteEnvironment(instance, project, environment) {
// Check if environment is protected
if (
Expand Down Expand Up @@ -158,7 +169,12 @@ export async function generateLoginLink(instance, project, environment) {
}
}

// Helper function to convert git URL to GitHub URL
/**
* Converts a Git SSH or HTTPS URL to a standard GitHub HTTPS URL without the `.git` suffix.
*
* @param {string} gitUrl - The Git repository URL to convert.
* @returns {string|null} The corresponding GitHub HTTPS URL, or {@code null} if the input is not a GitHub URL.
*/
export function gitUrlToGithubUrl(gitUrl) {
// Handle SSH URLs like git@github.com:org/repo.git
if (gitUrl.startsWith('git@github.com:')) {
Expand Down Expand Up @@ -187,3 +203,62 @@ export async function clearDrupalCache(instance, project, environment) {
throw new Error(`Failed to clear cache for environment ${environment}: ${error.message}`);
}
}

// No helper functions needed for the more efficient git ls-remote approach

// Get all branches from a git repository using ls-remote (much more efficient)
export async function getGitBranches(gitUrl) {
try {
if (!gitUrl) {
throw new Error('Git URL not provided or invalid');
}

// Use git ls-remote to list all references (only the heads/branches)
const command = `git ls-remote --heads ${gitUrl}`;
const { stdout } = await execAsync(command);

// Parse the branch names from the output
const branches = stdout
.split('\n')
.filter(line => line.trim().length > 0)
.map(line => {
// Extract the branch name from lines like:
// d7b0a24b046d00b6aeac1280e4d1a74297551444 refs/heads/main
const match = line.match(/refs\/heads\/(.+)$/);
return match ? match[1] : null;
})
.filter(branch => branch !== null);

return branches;
} catch (error) {
logError('List Branches', `git ls-remote ${gitUrl}`, error);
throw new Error(`Failed to get git branches: ${error.message}`);
}
}

// Deploy a branch to Lagoon
export async function deployBranch(instance, project, branch) {
try {
// Validate branch name to prevent command injection
if (!/^[a-zA-Z0-9_.-]+$/.test(branch)) {
throw new Error('Invalid branch name. Branch names must contain only alphanumeric characters, underscores, hyphens, and periods.');
}

const command = `lagoon -l ${instance} -p ${project} deploy branch --branch ${branch} --output-json`;
const { stdout } = await execLagoonCommand(command, `Deploy Branch ${branch} to ${project}`);

// Parse the JSON response
const response = JSON.parse(stdout);

if (response.result === 'success') {
return {
success: true,
message: `Branch ${branch} is being deployed to ${project}`
};
} else {
throw new Error(`Failed to deploy branch ${branch}: ${JSON.stringify(response)}`);
}
} catch (error) {
throw new Error(`Failed to deploy branch ${branch}: ${error.message}`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import os from 'os';
import yaml from 'js-yaml';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { execLagoonCommand } from './lagoon-api';
import { execLagoonCommand } from './lagoon-api.mjs';
import { logAction, logError } from './logger';

// Path to the .lagoon.yml file
Expand Down
18 changes: 10 additions & 8 deletions src/logger.js → src/logger.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ function getLogFilePath() {
}

/**
* Log a message to the daily log file
* @param {string} action - The action being performed in the CLI
* @param {string} command - The Lagoon CLI command being executed
* @param {string} [result] - Optional result of the command
* Appends an action entry with timestamp, action, command, and optional result to the current day's log file.
*
* @param {string} action - Description of the action performed.
* @param {string} command - The CLI command executed.
* @param {string} [result] - Optional result of the command.
*/
export function logAction(action, command, result = null) {
const timestamp = new Date().toISOString(); // ISO8601 timestamp
Expand All @@ -37,10 +38,11 @@ export function logAction(action, command, result = null) {
}

/**
* Log an error to the daily log file
* @param {string} action - The action being performed in the CLI
* @param {string} command - The Lagoon CLI command being executed
* @param {Error} error - The error that occurred
* Appends an error entry to the current day's log file with a timestamp, action, command, and error message.
*
* @param {string} action - Description of the action being performed.
* @param {string} command - The CLI command that was executed.
* @param {Error} error - The error encountered during execution.
*/
export function logError(action, command, error) {
const timestamp = new Date().toISOString(); // ISO8601 timestamp
Expand Down