Skip to content

Commit

Permalink
Doc: Fix menu link in branch name + stealth feature (#1048)
Browse files Browse the repository at this point in the history
* cfv0

* more

* v0.2

* Fix page menu + stealth

* changelog

* fix

* cspell

* factorize
  • Loading branch information
nvuillam authored Jan 31, 2025
1 parent bcc40fe commit 6953041
Show file tree
Hide file tree
Showing 8 changed files with 304 additions and 34 deletions.
2 changes: 2 additions & 0 deletions .github/linters/.cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,7 @@
"occurences",
"oclif",
"olas",
"onetimepin",
"openrc",
"op\u00e9ration",
"orgfreeze",
Expand Down Expand Up @@ -835,6 +836,7 @@
"setext",
"sfdevrc",
"sfdmu",
"sfdoc",
"sfdx",
"sfdxhardis",
"sfdxurl",
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

Note: Can be used with `sfdx plugins:install sfdx-hardis@beta` and docker image `hardisgroupcom/sfdx-hardis@beta`

## [5.17.4] 2025-01-31

- [hardis:doc:project2markdown](https://sfdx-hardis.cloudity.com/hardis/doc/project2markdown/): Fixes pages menu
- Stealth feature

## [5.17.3] 2025-01-29

- [hardis:doc:project2markdown](https://sfdx-hardis.cloudity.com/hardis/doc/project2markdown/): Improve Apex docs markdown
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"bitbucket": "^2.12.0",
"chalk": "^5.4.1",
"chrome-launcher": "^1.1.2",
"cloudflare": "^4.0.0",
"columnify": "^1.6.0",
"cosmiconfig": "^9.0.0",
"cross-spawn": "^7.0.6",
Expand Down
249 changes: 249 additions & 0 deletions src/commands/hardis/doc/mkdocs-to-cf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
/* jscpd:ignore-start */
import { SfCommand, Flags, requiredOrgFlagWithDeprecations } 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 { AnyJson } from '@salesforce/ts-types';
import Cloudflare from 'cloudflare';
import { execCommand, getCurrentGitBranch, uxLog } from '../../../common/utils/index.js';

import { CONSTANTS } from '../../../config/index.js';
import which from 'which';
import { generateMkDocsHTML } from '../../../common/utils/docUtils.js';


Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');

export default class MkDocsToCloudflare extends SfCommand<any> {
public static title = 'MkDocs to Cloudflare';

public 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_KEY\` | Cloudflare API key | <!--- 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 | \`@cloudity.com\` |
`;

public static examples = [
'$ sf hardis:doc:mkdocs-to-cf',
'$ [email protected] CLOUDFLARE_API_TOKEN=zzzzzz CLOUDFLARE_ACCOUNT_ID=zzzzz sf hardis:doc:mkdocs-to-cf',
];

public static flags: any = {
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',
}),
'target-org': requiredOrgFlagWithDeprecations,
};

// Set this to true if your command requires a project workspace; 'requiresProject' is false by default
public static requiresProject = true;

protected debugMode = false;
protected apiEmail: string | undefined;
protected apiToken: string | undefined;
protected accountId: string | undefined;
protected client: Cloudflare;
protected projectName: string | null;
protected currentGitBranch: string | null;
protected defaultLoginMethodType: string = process.env.CLOUDFLARE_DEFAULT_LOGIN_METHOD_TYPE || "onetimepin";
protected defaultAccessEmailDomain: string = process.env.CLOUDFLARE_DEFAULT_ACCESS_EMAIL_DOMAIN || "@cloudity.com";
protected pagesProjectName: string;
protected pagesProject: Cloudflare.Pages.Projects.Project;
protected accessPolicyName: string;
protected accessPolicy: Cloudflare.ZeroTrust.Access.Policies.PolicyGetResponse | null;
protected accessApp: Cloudflare.ZeroTrust.Access.Applications.ApplicationGetResponse.SelfHostedApplication | null;

/* jscpd:ignore-end */

public async run(): Promise<AnyJson> {
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 = (process.env.CLOUDFLARE_PROJECT_NAME || this.currentGitBranch).replace(/\//g, "-").toLowerCase();
this.pagesProjectName = `sfdoc-${this.projectName}`;
this.accessPolicyName = `access-policy-${this.projectName}`;

// Create connection to Cloudflare
this.setupCloudflareClient();

// Generate HTML pages
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 };
}

private 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"));
}

private 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: any) {
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)));
}

private 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: Cloudflare.ZeroTrust.Access.Policies.PolicyGetResponse) => 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: Cloudflare.ZeroTrust.IdentityProviders.IdentityProviderListResponse) => 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: [
{ login_method: { id: defaultLoginMethod.id } }
],
require: [
{
email_domain: { domain: this.defaultAccessEmailDomain },
}
],
} as any);
uxLog(this, c.green("Cloudflare policy created: " + this.accessPolicyName));
}
uxLog(this, c.grey(JSON.stringify(this.accessPolicy, null, 2)));
}

private 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: Cloudflare.ZeroTrust.Access.Applications.ApplicationListResponse) => a.name === this.pagesProject?.domains?.[0]) || null) as any;
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]}`
}
]
}) as Cloudflare.ZeroTrust.Access.Applications.ApplicationGetResponse.SelfHostedApplication);
uxLog(this, c.green("Cloudflare access application created: " + this.pagesProject?.domains?.[0]));
}
uxLog(this, c.grey(JSON.stringify(this.accessApp, null, 2)));
}

private 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 {
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: [this.accessPolicy?.id || ""],
} as Cloudflare.ZeroTrust.Access.ApplicationUpdateParams)) as Cloudflare.ZeroTrust.Access.Applications.ApplicationGetResponse.SelfHostedApplication;
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)));
}

private async uploadHtmlPages() {
uxLog(this, c.cyan("Uploading HTML pages to Cloudflare Pages..."));
let wranglerCommand = `wrangler pages publish ./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 });
}

}
34 changes: 2 additions & 32 deletions src/commands/hardis/doc/mkdocs-to-salesforce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { createTempDir, execCommand, isCI, uxLog } from '../../../common/utils/i
import { createBlankSfdxProject } from '../../../common/utils/projectUtils.js';
import { initPermissionSetAssignments, isProductionOrg } from '../../../common/utils/orgUtils.js';
import { CONSTANTS } from '../../../config/index.js';
import { readMkDocsFile, writeMkDocsFile } from '../../../common/utils/docUtils.js';
import { generateMkDocsHTML, readMkDocsFile, writeMkDocsFile } from '../../../common/utils/docUtils.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
Expand Down Expand Up @@ -88,7 +88,7 @@ More info on [Documentation section](${CONSTANTS.DOC_URL_ROOT}/salesforce-projec

try {
// Generate HTML pages
await this.generateMkDocsHTML();
await generateMkDocsHTML();

// Create temp sfdx project
const tmpDirForSfdxProject = await createTempDir();
Expand Down Expand Up @@ -125,36 +125,6 @@ More info on [Documentation section](${CONSTANTS.DOC_URL_ROOT}/salesforce-projec
}
}

private async installMkDocs() {
uxLog(this, c.cyan("Managing mkdocs-material local installation..."));
let mkdocsLocalOk = false;
const installMkDocsRes = await execCommand("pip install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists || python -m install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists || py -m install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists", this, { fail: false, output: true, debug: this.debugMode });
if (installMkDocsRes.status === 0) {
mkdocsLocalOk = true;
}
return mkdocsLocalOk;
}

private async generateMkDocsHTML() {
const mkdocsLocalOk = await this.installMkDocs();
if (mkdocsLocalOk) {
// Generate MkDocs HTML pages with local MkDocs
uxLog(this, c.cyan("Generating HTML pages with mkdocs..."));
const mkdocsBuildRes = await execCommand("mkdocs build || python -m mkdocs build || py -m mkdocs build", this, { fail: false, output: true, debug: this.debugMode });
if (mkdocsBuildRes.status !== 0) {
throw new SfError('MkDocs build failed:\n' + mkdocsBuildRes.stderr + "\n" + mkdocsBuildRes.stdout);
}
}
else {
// Generate MkDocs HTML pages with Docker
uxLog(this, c.cyan("Generating HTML pages with Docker..."));
const mkdocsBuildRes = await execCommand("docker run --rm -v $(pwd):/docs squidfunk/mkdocs-material build", this, { fail: false, output: true, debug: this.debugMode });
if (mkdocsBuildRes.status !== 0) {
throw new SfError('MkDocs build with docker failed:\n' + mkdocsBuildRes.stderr + "\n" + mkdocsBuildRes.stdout);
}
}
}

private async createDocMetadatas(resName: string, defaultProjectPath: string, type: any) {
uxLog(this, c.cyan(`Creating Static Resource ${resName} metadata...`));
const staticResourcePath = path.join(defaultProjectPath, "staticresources");
Expand Down
2 changes: 1 addition & 1 deletion src/commands/hardis/doc/project2markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ ${Project2Markdown.htmlInstructions}
const pageFiles = await listPageFiles(packageDirs);
const pagesForMenu: any = { "All Lightning pages": "pages/index.md" }
for (const pagefile of pageFiles) {
const pageName = path.basename(pagefile, "flexipage-meta.xml");
const pageName = path.basename(pagefile, ".flexipage-meta.xml");
const mdFile = path.join(this.outputMarkdownRoot, "pages", pageName + ".md");
pagesForMenu[pageName] = "pages/" + pageName + ".md";
// Add apex code in documentation
Expand Down
32 changes: 31 additions & 1 deletion src/common/utils/docUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as path from "path";
import c from 'chalk';
import fs from 'fs-extra';
import { uxLog } from "./index.js";
import { execCommand, uxLog } from "./index.js";
import * as yaml from 'js-yaml';
import { countPackageXmlItems, parsePackageXmlFile } from "./xmlUtils.js";
import { CONSTANTS } from "../../config/index.js";
Expand Down Expand Up @@ -393,3 +393,33 @@ export async function replaceInFile(filePath: string, stringToReplace: string, r
const newContent = fileContent.replaceAll(stringToReplace, replaceWith);
await fs.writeFile(filePath, newContent);
}

export async function generateMkDocsHTML() {
const mkdocsLocalOk = await installMkDocs();
if (mkdocsLocalOk) {
// Generate MkDocs HTML pages with local MkDocs
uxLog(this, c.cyan("Generating HTML pages with mkdocs..."));
const mkdocsBuildRes = await execCommand("mkdocs build -v || python -m mkdocs build -v || py -m mkdocs build -v", this, { fail: false, output: true, debug: this.debugMode });
if (mkdocsBuildRes.status !== 0) {
throw new SfError('MkDocs build failed:\n' + mkdocsBuildRes.stderr + "\n" + mkdocsBuildRes.stdout);
}
}
else {
// Generate MkDocs HTML pages with Docker
uxLog(this, c.cyan("Generating HTML pages with Docker..."));
const mkdocsBuildRes = await execCommand("docker run --rm -v $(pwd):/docs squidfunk/mkdocs-material build -v", this, { fail: false, output: true, debug: this.debugMode });
if (mkdocsBuildRes.status !== 0) {
throw new SfError('MkDocs build with docker failed:\n' + mkdocsBuildRes.stderr + "\n" + mkdocsBuildRes.stdout);
}
}
}

export async function installMkDocs() {
uxLog(this, c.cyan("Managing mkdocs-material local installation..."));
let mkdocsLocalOk = false;
const installMkDocsRes = await execCommand("pip install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists || python -m install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists || py -m install mkdocs-material mkdocs-exclude-search mdx_truly_sane_lists", this, { fail: false, output: true, debug: this.debugMode });
if (installMkDocsRes.status === 0) {
mkdocsLocalOk = true;
}
return mkdocsLocalOk;
}
Loading

0 comments on commit 6953041

Please sign in to comment.