sfdx-hardis
Version:
Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards
234 lines (225 loc) • 11.9 kB
JavaScript
/* jscpd:ignore-start */
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import fs from 'fs-extra';
import c from "chalk";
import * as path from "path";
import { Messages, SfError } from '@salesforce/core';
import Cloudflare from 'cloudflare';
import { execCommand, getCurrentGitBranch, uxLog } from '../../../common/utils/index.js';
import { CONSTANTS, getEnvVar } from '../../../config/index.js';
import which from 'which';
import { generateMkDocsHTML } from '../../../common/docBuilder/docUtils.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class MkDocsToCloudflare extends SfCommand {
static title = 'MkDocs to Cloudflare';
static description = `Generates MkDocs HTML pages and upload them to Cloudflare as a static pages
This command performs the following operations:
- Generates MkDocs HTML pages (using locally installed mkdocs-material, or using mkdocs docker image)
- Creates a Cloudflare pages app
- Assigns a policy restricting access to the application
- Opens the new WebSite in the default browser (only if not in CI context)
Note: the documentation must have been previously generated using "sf hardis:doc:project2markdown --with-history"
You can:
- Override default styles by customizing mkdocs.yml
More info on [Documentation section](${CONSTANTS.DOC_URL_ROOT}/salesforce-project-documentation/)
| Variable | Description | Default |
| :----------------------------------------- | :---------- | :-----: |
| \`CLOUDFLARE_EMAIL\` | Cloudflare account email | <!--- Required --> |
| \`CLOUDFLARE_API_TOKEN\` | Cloudflare API token | <!--- Required --> |
| \`CLOUDFLARE_ACCOUNT_ID\` | Cloudflare account | <!--- Required --> |
| \`CLOUDFLARE_PROJECT_NAME\` | Project name, that will also be used for site URL | Built from git branch name |
| \`CLOUDFLARE_DEFAULT_LOGIN_METHOD_TYPE\` | Cloudflare default login method type | \`onetimepin\` |
| \`CLOUDFLARE_DEFAULT_ACCESS_EMAIL_DOMAIN\` | Cloudflare default access email domain | \`.com\` |
| \`CLOUDFLARE_EXTRA_ACCESS_POLICY_ID_LIST\` | Policies to assign to every application access | <!--- Optional --> |
`;
static examples = [
'$ sf hardis:doc:mkdocs-to-cf',
];
static flags = {
debug: Flags.boolean({
char: 'd',
default: false,
description: messages.getMessage('debugMode'),
}),
websocket: Flags.string({
description: messages.getMessage('websocket'),
}),
skipauth: Flags.boolean({
description: 'Skip authentication check when a default username is required',
})
};
// Set this to true if your command requires a project workspace; 'requiresProject' is false by default
static requiresProject = true;
debugMode = false;
apiEmail;
apiToken;
accountId;
client;
projectName;
currentGitBranch;
defaultLoginMethodType = process.env.CLOUDFLARE_DEFAULT_LOGIN_METHOD_TYPE || "onetimepin";
defaultAccessEmailDomain = process.env.CLOUDFLARE_DEFAULT_ACCESS_EMAIL_DOMAIN || "@cloudity.com";
pagesProjectName;
pagesProject;
accessPolicyName;
accessPolicy;
extraPolicyIds = (process.env.CLOUDFLARE_EXTRA_ACCESS_POLICY_ID_LIST || "").split(",").filter(p => p);
accessApp;
/* jscpd:ignore-end */
async run() {
const { flags } = await this.parse(MkDocsToCloudflare);
this.debugMode = flags.debug || false;
// Check if the project is a MkDocs project
const mkdocsYmlFile = path.join(process.cwd(), "mkdocs.yml");
if (!fs.existsSync(mkdocsYmlFile)) {
throw new SfError('This command needs a mkdocs.yml config file. Generate one using "sf hardis:doc:project2markdown --with-history"');
}
this.currentGitBranch = await getCurrentGitBranch() || "main";
this.projectName = await this.getProjectName();
this.pagesProjectName = `sfdoc-${this.projectName}`;
this.accessPolicyName = `access-policy-${this.projectName}`;
// Create connection to Cloudflare
this.setupCloudflareClient();
// Generate HTML pages
if ((process.env?.SKIP_BUILD_HTML || "false") !== "true") {
await generateMkDocsHTML();
}
// Get or Create Cloudflare Pages project
await this.ensureCloudflarePagesProject();
// Ensure there is a policy restricting access to the application
await this.ensureCloudflareAccessPolicy();
// Ensure there is an access application
await this.ensureCloudflareAccessApplication();
// Ensure the access application has the right policy
await this.ensureCloudflareAccessApplicationPolicy();
// Upload pages
await this.uploadHtmlPages();
return { success: true };
}
// Search first for CLOUDFLARE_PROJECT_NAME_<lang> env var, then CLOUDFLARE_PROJECT_NAME, then git branch name
// If none of them is found, use the default project name
async getProjectName() {
const defaultProjectName = (getEnvVar('CLOUDFLARE_PROJECT_NAME') || this.currentGitBranch).replace(/\//g, "-").toLowerCase();
const promptsLanguage = getEnvVar('PROMPTS_LANGUAGE') || 'en';
const languageScopedProjectVariableName = `CLOUDFLARE_PROJECT_NAME_${promptsLanguage?.toUpperCase()}`;
if (getEnvVar(languageScopedProjectVariableName)) {
return getEnvVar(languageScopedProjectVariableName) || defaultProjectName;
}
return defaultProjectName;
}
setupCloudflareClient() {
this.apiEmail = process.env.CLOUDFLARE_EMAIL;
this.apiToken = process.env.CLOUDFLARE_API_TOKEN;
this.accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
if (!this.apiEmail || !this.accountId || !this.apiToken) {
throw new Error('Missing CLOUDFLARE_EMAIL or CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID');
}
this.client = new Cloudflare({
apiEmail: this.apiEmail,
apiToken: this.apiToken,
});
uxLog(this, c.grey("Cloudflare client info found"));
}
async ensureCloudflarePagesProject() {
uxLog(this, c.cyan("Checking Cloudflare Pages project..."));
try {
this.pagesProject = await this.client.pages.projects.get(this.pagesProjectName, { account_id: this.accountId || "" });
uxLog(this, c.cyan("Cloudflare Pages project found: " + this.pagesProjectName));
}
catch (e) {
uxLog(this, c.grey(e.message));
this.pagesProject = await this.client.pages.projects.create({
name: this.pagesProjectName,
account_id: this.accountId || "",
production_branch: this.currentGitBranch || "main",
});
uxLog(this, c.green("Cloudflare Pages project created: " + this.pagesProjectName));
}
uxLog(this, c.grey(JSON.stringify(this.pagesProject, null, 2)));
}
async ensureCloudflareAccessPolicy() {
uxLog(this, c.cyan("Checking Cloudflare Access policy..."));
const accessPolicies = await this.client.zeroTrust.access.policies.list({ account_id: this.accountId || "" });
this.accessPolicy = accessPolicies.result.find((p) => p.name === this.accessPolicyName) || null;
if (this.accessPolicy) {
uxLog(this, c.cyan("Cloudflare policy found: " + this.accessPolicyName));
}
else {
const loginMethods = await this.client.zeroTrust.identityProviders.list({ account_id: this.accountId || "" });
const defaultLoginMethod = loginMethods.result.find((m) => m.type === this.defaultLoginMethodType);
if (!defaultLoginMethod) {
throw new SfError(`No login method of type ${this.defaultLoginMethodType} found in Cloudflare account. Please create one in Zero Trust/Settings before running this command`);
}
this.accessPolicy = await this.client.zeroTrust.access.policies.create({
name: this.accessPolicyName,
account_id: this.accountId || "",
decision: "allow",
include: [
{ email_domain: { domain: this.defaultAccessEmailDomain } },
],
require: [
{ login_method: { id: defaultLoginMethod.id } }
],
});
uxLog(this, c.green("Cloudflare policy created: " + this.accessPolicyName));
}
uxLog(this, c.grey(JSON.stringify(this.accessPolicy, null, 2)));
}
async ensureCloudflareAccessApplication() {
uxLog(this, c.cyan("Checking Cloudflare access application..."));
const accessApplications = await this.client.zeroTrust.access.applications.list({ account_id: this.accountId || "" });
this.accessApp = (accessApplications.result.find((a) => a.name === this.pagesProject?.domains?.[0]) || null);
if (this.accessApp) {
uxLog(this, c.cyan("Cloudflare access application found: " + this.pagesProject?.domains?.[0]));
}
else {
this.accessApp = await this.client.zeroTrust.access.applications.create({
name: this.pagesProject?.domains?.[0],
account_id: this.accountId || "",
type: "self_hosted",
domain: this.pagesProject?.domains?.[0],
destinations: [
{
"type": "public",
"uri": `${this.pagesProject?.domains?.[0]}`
},
{
"type": "public",
"uri": `*.${this.pagesProject?.domains?.[0]}`
}
]
});
uxLog(this, c.green("Cloudflare access application created: " + this.pagesProject?.domains?.[0]));
}
uxLog(this, c.grey(JSON.stringify(this.accessApp, null, 2)));
}
async ensureCloudflareAccessApplicationPolicy() {
uxLog(this, c.cyan("Checking Cloudflare access application policy..."));
if (this.accessApp?.policies?.length && this.accessApp.policies.find(p => p.id === this.accessPolicy?.id)) {
uxLog(this, c.cyan(`Access Application ${this.accessApp.name} already has the policy ${this.accessPolicy?.name}`));
}
else {
const policiesWithExtra = this.extraPolicyIds.concat([this.accessPolicy?.id || ""]).filter(p => p);
this.accessApp = (await this.client.zeroTrust.access.applications.update(this.accessApp?.id || "", {
account_id: this.accountId,
domain: this.accessApp?.domain,
destinations: this.accessApp?.destinations,
type: this.accessApp?.type,
policies: policiesWithExtra,
}));
uxLog(this, c.green(`Access Application ${this.accessApp?.name} updated with the policy ${this.accessPolicy?.name}`));
}
uxLog(this, c.grey(JSON.stringify(this.accessApp, null, 2)));
}
async uploadHtmlPages() {
uxLog(this, c.cyan("Uploading HTML pages to Cloudflare Pages..."));
let wranglerCommand = `wrangler pages deploy ./site --project-name="${this.pagesProjectName}" --branch=${this.currentGitBranch}`;
const isWranglerAvailable = await which("wrangler", { nothrow: true });
if (!isWranglerAvailable) {
wranglerCommand = "npx --yes " + wranglerCommand;
}
await execCommand(wranglerCommand, this, { fail: true, output: true, debug: this.debugMode });
}
}
//# sourceMappingURL=mkdocs-to-cf.js.map