@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
469 lines • 13.5 kB
JavaScript
import fs from 'fs';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { diff, getCurrentBranch, hasLocalChanges, isReleaseBranch } from '../../utils/git.js';
import { logger } from '../../utils/logger.js';
import { execSync, selectProject } from '../../utils/index.js';
import { normalizeOptionalString } from '../../utils/value.js';
const DEFAULT_INITIAL_RELEASE_TAG = '0.0.0';
const RELEASE_TAG_FORMAT = /^(?<prefix>v?)(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<revision>\d+))?$/u;
const getCloudBuildUrl = projectId => `https://console.cloud.google.com/cloud-build/builds?project=${projectId}`;
const parseReleaseTag = versionTag => {
const normalizedTag = normalizeOptionalString(versionTag);
if (!normalizedTag) {
return null;
}
const parts = RELEASE_TAG_FORMAT.exec(normalizedTag);
if (!parts?.groups) {
return null;
}
return {
major: Number.parseInt(parts.groups.major, 10),
minor: Number.parseInt(parts.groups.minor, 10),
patch: Number.parseInt(parts.groups.patch, 10),
prefix: parts.groups.prefix ?? '',
tag: normalizedTag
};
};
export const getAvailableVersions = latestReleaseTag => {
const parsedVersion = parseReleaseTag(latestReleaseTag) ?? parseReleaseTag(DEFAULT_INITIAL_RELEASE_TAG);
if (!parsedVersion) {
throw new Error('The latest GitHub release tag must use semantic versioning, for example 1.2.3 or v1.2.3.');
}
const {
major,
minor,
patch,
prefix
} = parsedVersion;
return [{
name: 'Patch',
version: `${prefix}${major}.${minor}.${patch + 1}`
}, {
name: 'Minor',
version: `${prefix}${major}.${minor + 1}.0`
}, {
name: 'Major',
version: `${prefix}${major + 1}.0.0`
}];
};
const getLatestReleaseContext = (dependencies = {}) => {
const {
execSyncImpl = execSync,
loggerImpl = logger
} = dependencies;
const spinner = loggerImpl.spinner('Fetching current release from GitHub...');
try {
const releaseList = JSON.parse(execSyncImpl('gh release list', {
excludeDrafts: true,
json: 'tagName',
limit: 1,
stdio: 'pipe'
}).toString());
const latestReleaseTag = normalizeOptionalString(releaseList[0]?.tagName);
if (!latestReleaseTag) {
spinner.stop();
loggerImpl.warning('No releases found on GitHub. This is the first release.');
return {
isFirstRelease: true,
latestReleaseTag: null
};
}
if (!parseReleaseTag(latestReleaseTag)) {
spinner.stop();
throw new Error(`Latest GitHub release tag ${latestReleaseTag} is not a supported semantic version. Use a tag like 1.2.3 or v1.2.3.`);
}
spinner.succeed(`Latest release: ${latestReleaseTag}`);
return {
isFirstRelease: false,
latestReleaseTag
};
} catch (error) {
spinner.fail('Failed to fetch the latest release from GitHub.');
throw error;
}
};
const ensureGitHubAuth = async (dependencies = {}) => {
const {
execSyncImpl = execSync,
loggerImpl = logger,
promptImpl = inquirer.prompt
} = dependencies;
try {
execSyncImpl('gh --version', {
stdio: 'ignore'
});
} catch (error) {
throw new Error(`GitHub CLI is required to continue. Install it first: https://cli.github.com/. ${error.message}`);
}
try {
execSyncImpl('gh auth status', {
stdio: 'ignore'
});
} catch (error) {
loggerImpl.warning(`GitHub CLI is not authenticated: ${error.message}`);
const {
shouldLogin
} = await promptImpl([{
type: 'confirm',
name: 'shouldLogin',
default: true,
message: 'GitHub authentication is required to create a release and trigger Google Cloud Build. Log in now?'
}]);
if (!shouldLogin) {
throw new Error('GitHub authentication is required to create the release and trigger the production deploy.');
}
execSyncImpl('gh auth login', {
stdio: 'inherit',
web: true
});
}
};
const outputReleasePlanSummary = ({
createDraft,
currentBranch,
hasWorkingTreeChanges,
latestReleaseTag,
loggerImpl = logger,
projectId,
releaseType,
version
}) => {
loggerImpl.summary('Release plan', [{
label: 'Project',
value: projectId,
tone: 'warning'
}, {
label: 'Branch',
value: currentBranch,
tone: 'accent'
}, {
label: 'Latest release',
value: latestReleaseTag ?? 'none (first release)',
tone: latestReleaseTag ? 'accent' : 'warning'
}, {
label: 'Release type',
value: releaseType,
tone: 'warning'
}, {
label: 'Next release',
value: version,
tone: 'warning'
}, {
label: 'GitHub release',
value: createDraft ? 'draft' : 'published',
tone: createDraft ? 'warning' : 'success'
}, {
label: 'Cloud Build',
value: 'Triggered automatically after the GitHub release is created',
tone: 'accent'
}, {
label: 'Working tree',
value: hasWorkingTreeChanges ? 'local changes will not be included' : 'clean',
tone: hasWorkingTreeChanges ? 'warning' : 'success'
}], {
spacing: 'after'
});
};
const outputDeploymentOutcomeSummary = ({
createDraft,
loggerImpl = logger,
projectId,
version
}) => {
loggerImpl.summary('Deployment outcome', [{
label: 'Project',
value: projectId,
tone: 'warning'
}, {
label: 'Release tag',
value: version,
tone: 'accent'
}, {
label: 'GitHub release',
value: createDraft ? 'draft created' : 'release created',
tone: createDraft ? 'warning' : 'success'
}, {
label: 'Cloud Build',
value: getCloudBuildUrl(projectId),
tone: 'accent'
}], {
spacing: 'after'
});
};
const outputFollowUpSummary = ({
deployCronJobs,
deployFirestoreIndexes,
deployFirestoreRules,
loggerImpl = logger
}) => {
loggerImpl.summary('Optional follow-up deploys', [{
label: 'Firestore rules',
value: deployFirestoreRules ? 'enabled' : 'skipped',
tone: deployFirestoreRules ? 'warning' : null
}, {
label: 'Firestore indexes',
value: deployFirestoreIndexes ? 'enabled' : 'skipped',
tone: deployFirestoreIndexes ? 'warning' : null
}, {
label: 'Cron jobs',
value: deployCronJobs ? 'enabled' : 'skipped',
tone: deployCronJobs ? 'warning' : null
}], {
spacing: 'after'
});
};
const warnAboutChangedFunctionsSinceRelease = ({
currentBranch,
diffImpl = diff,
latestReleaseTag,
loggerImpl = logger
}) => {
if (!latestReleaseTag) {
return;
}
const hasChanges = normalizeOptionalString(diffImpl(latestReleaseTag, currentBranch, {
path: 'functions'
}));
if (!hasChanges) {
return;
}
loggerImpl.summary('Manual follow-up recommended', [{
label: 'Directory',
value: 'functions',
tone: 'accent'
}, {
label: 'Since release',
value: latestReleaseTag,
tone: 'warning'
}, {
label: 'Action',
value: 'Deploy changed Firebase functions manually if needed',
tone: 'warning'
}], {
spacing: 'after'
});
};
const promptForDeploymentPlan = async ({
projectId,
promptImpl = inquirer.prompt,
versionChoices
}) => {
const {
releaseSelection
} = await promptImpl([{
type: 'select',
name: 'releaseSelection',
message: 'Select the version you want to release:',
choices: versionChoices.map(({
name,
version
}) => ({
name: `${version} (${name})`,
value: {
releaseType: name,
version
},
short: version
}))
}]);
const {
shouldPublishRelease
} = await promptImpl([{
type: 'confirm',
name: 'shouldPublishRelease',
default: true,
message: 'Publish the GitHub release immediately? If not, a draft release will be created instead.'
}]);
const {
isConfirmed
} = await promptImpl([{
type: 'confirm',
name: 'isConfirmed',
default: false,
message: `Create ${shouldPublishRelease ? 'and publish' : 'as draft'} GitHub release ` + `${chalk.yellow(releaseSelection.version)} in ${chalk.yellow(projectId)}? ` + 'This triggers the production Google Cloud Build/App Engine deploy.'
}]);
if (!isConfirmed) {
return {
createDraft: !shouldPublishRelease,
deployCronJobs: false,
deployFirestoreIndexes: false,
deployFirestoreRules: false,
isConfirmed,
releaseType: releaseSelection.releaseType,
version: releaseSelection.version
};
}
const {
deployFirestoreRules
} = await promptImpl([{
type: 'confirm',
name: 'deployFirestoreRules',
default: false,
message: `Do you want to deploy ${chalk.yellow('Firestore rules')} ` + 'from firebase.json as well?'
}]);
const {
deployFirestoreIndexes
} = await promptImpl([{
type: 'confirm',
name: 'deployFirestoreIndexes',
default: false,
message: `Do you want to deploy ${chalk.yellow('Firestore indexes')} ` + 'from firebase.json as well?'
}]);
const {
deployCronJobs
} = await promptImpl([{
type: 'confirm',
name: 'deployCronJobs',
default: false,
message: `Do you want to deploy ${chalk.yellow('cron jobs')} from cron.yaml as well?`
}]);
return {
createDraft: !shouldPublishRelease,
deployCronJobs,
deployFirestoreIndexes,
deployFirestoreRules,
isConfirmed,
releaseType: releaseSelection.releaseType,
version: releaseSelection.version
};
};
export const createDeployAppHandler = (dependencies = {}) => {
const {
diffImpl = diff,
execSyncImpl = execSync,
existsSyncImpl = fs.existsSync,
getCurrentBranchImpl = getCurrentBranch,
hasLocalChangesImpl = hasLocalChanges,
isReleaseBranchImpl = isReleaseBranch,
loggerImpl = logger,
promptImpl = inquirer.prompt,
selectProjectImpl = selectProject,
unlinkSyncImpl = fs.unlinkSync
} = dependencies;
return async () => {
try {
if (!isReleaseBranchImpl()) {
loggerImpl.error('You can only deploy from a release branch (main, master or x.y.x).', {
exit: true,
exitCode: 1
});
return false;
}
if (!existsSyncImpl('./cloudbuild.yaml')) {
loggerImpl.error('No cloudbuild.yaml file found. Make sure you run this command in the root of the Atlas project.', {
exit: true,
exitCode: 1
});
return false;
}
const currentBranch = getCurrentBranchImpl();
const hasWorkingTreeChanges = hasLocalChangesImpl();
const {
projectId
} = await selectProjectImpl('.firebaserc', {
environment: 'production'
});
await ensureGitHubAuth({
execSyncImpl,
loggerImpl,
promptImpl
});
const {
isFirstRelease,
latestReleaseTag
} = getLatestReleaseContext({
execSyncImpl,
loggerImpl
});
const versionChoices = getAvailableVersions(latestReleaseTag);
const releasePlan = await promptForDeploymentPlan({
projectId,
promptImpl,
versionChoices
});
outputReleasePlanSummary({
createDraft: releasePlan.createDraft,
currentBranch,
hasWorkingTreeChanges,
latestReleaseTag,
loggerImpl,
projectId,
releaseType: releasePlan.releaseType,
version: releasePlan.version
});
if (!releasePlan.isConfirmed) {
loggerImpl.warning('Deployment cancelled by the user.');
return false;
}
const releaseOptions = {
generateNotes: true,
target: currentBranch,
title: releasePlan.version
};
if (releasePlan.createDraft) {
releaseOptions.draft = true;
}
if (!isFirstRelease && latestReleaseTag) {
releaseOptions.notesStartTag = latestReleaseTag;
}
execSyncImpl(`gh release create ${releasePlan.version}`, releaseOptions);
outputDeploymentOutcomeSummary({
createDraft: releasePlan.createDraft,
loggerImpl,
projectId,
version: releasePlan.version
});
outputFollowUpSummary({
deployCronJobs: releasePlan.deployCronJobs,
deployFirestoreIndexes: releasePlan.deployFirestoreIndexes,
deployFirestoreRules: releasePlan.deployFirestoreRules,
loggerImpl
});
if (releasePlan.deployFirestoreRules || releasePlan.deployFirestoreIndexes) {
const firebaseTargets = [];
if (releasePlan.deployFirestoreRules) {
firebaseTargets.push('firestore:rules');
}
if (releasePlan.deployFirestoreIndexes) {
firebaseTargets.push('firestore:indexes');
}
execSyncImpl('firebase deploy', {
only: firebaseTargets.join(','),
project: projectId
});
}
if (releasePlan.deployCronJobs) {
execSyncImpl('gcloud app deploy cron.yaml', {
project: projectId
});
}
warnAboutChangedFunctionsSinceRelease({
currentBranch,
diffImpl,
latestReleaseTag,
loggerImpl
});
loggerImpl.success('Done!');
return true;
} catch (error) {
if (error instanceof Error && error.name === 'ExitPromptError') {
loggerImpl.error('Deployment cancelled by the user.', {
exit: true,
exitCode: 130
});
return false;
}
loggerImpl.error(error, {
exit: true,
exitCode: 1
});
return false;
} finally {
if (existsSyncImpl('release.txt')) {
unlinkSyncImpl('release.txt');
}
}
};
};
export default createDeployAppHandler();