Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
160 changes: 156 additions & 4 deletions 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
} from './lagoon-api';
import { logAction } from './logger';
import { configureSshKey } from './lagoon-ssh-key-configurator';
extractPrNumber,
getGitBranches,
deployBranch
} from './lagoon-api.mjs';
import { logAction } from './logger.mjs';
import { configureSshKey } from './lagoon-ssh-key-configurator.mjs';

// 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,129 @@ 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 if available, or regular list if not
let selectedBranch;

// Check if the autocomplete prompt is registered
if (inquirer.prompt.prompts.autocomplete) {
// Use autocomplete selection
const answer = await inquirer.prompt([
{
type: 'autocomplete',
name: 'branch',
message: 'Select a branch to deploy:',
source: (answersSoFar, input = '') => {
return Promise.resolve(
sortedBranches.filter(branch =>
!input || branch.toLowerCase().includes(input.toLowerCase())
)
);
}
}
]);
selectedBranch = answer.branch;
} else {
// Fall back to regular list selection
const answer = await inquirer.prompt([
{
type: 'list',
name: 'branch',
message: 'Select a branch to deploy:',
choices: sortedBranches
}
]);
selectedBranch = answer.branch;
}

// 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...'
}
]);
}
105 changes: 102 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,86 @@ 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}`;
console.log(chalk.blue(`Executing: ${chalk.bold(command)}`));

try {
const { stdout, stderr } = await execAsync(command);

// Log any warnings from stderr
if (stderr) {
console.log(chalk.yellow(`Warning: ${stderr}`));
}

// 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);

// Log the number of branches found
console.log(chalk.green(`Found ${branches.length} branches in repository`));

return branches;
} catch (error) {
// If the Git URL is using SSH, offer a hint about authentication
if (gitUrl.startsWith('git@') && error.message.includes('Permission denied')) {
throw new Error(`Authentication failed for ${gitUrl}. Please check your SSH key configuration.`);
} else if (error.message.includes('not found')) {
throw new Error(`Repository not found: ${gitUrl}. Please check if the URL is correct.`);
} else {
throw error;
}
}
} 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 - allow slashes in addition to other safe characters
if (!/^[a-zA-Z0-9_./\-]+$/.test(branch)) {
throw new Error('Invalid branch name. Branch names must contain only alphanumeric characters, slashes, underscores, hyphens, and periods.');
}

// Properly escape the branch name for use in command line
const escapedBranch = branch.replace(/"/g, '\\"');

const command = `lagoon -l ${instance} -p ${project} deploy branch --branch "${escapedBranch}" --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
Loading