diff --git a/automation/run-e2e/lib/dev.mjs b/automation/run-e2e/lib/dev.mjs index 0a4b20e389..68c897d59c 100644 --- a/automation/run-e2e/lib/dev.mjs +++ b/automation/run-e2e/lib/dev.mjs @@ -1,14 +1,17 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync, execSync } from "node:child_process"; import { delimiter } from "node:path"; import { fileURLToPath } from "node:url"; import parseArgs from "yargs-parser"; import c from "ansi-colors"; import enquirer from "enquirer"; +import sh from "shelljs"; import { setupTestProject } from "./setup-test-project.mjs"; import { updateTestProject } from "./update-test-project.mjs"; import { await200 } from "./utils.mjs"; import * as config from "./config.mjs"; +const { ls, exec } = sh; + export async function dev() { console.log(c.cyan("Run e2e tests in development environment")); @@ -29,6 +32,20 @@ export async function dev() { } }; + if (!process.env.GITHUB_TOKEN) { + console.log("GITHUB_TOKEN not found. Fetching from GitHub CLI..."); + + const result = exec("gh auth token", { silent: true }); + + if (result.code === 0) { + process.env.GITHUB_TOKEN = result.stdout.trim(); + console.log("Successfully set GITHUB_TOKEN from gh CLI."); + } else { + console.error('Error: Could not retrieve token. Ensure you are logged in via "gh auth login".'); + process.exit(1); + } + } + // We add local node_modules/.bin to PATH to make cypress bin is available for // any package in monorepo. const packageBinariesPath = fileURLToPath(new URL("../node_modules/.bin", import.meta.url)); @@ -53,6 +70,29 @@ export async function dev() { ) ); + // Print out Mendix version from MPR file + try { + const mprFiles = ls(config.mprFileGlob); + if (mprFiles.length > 0) { + const mprFile = mprFiles[0]; + try { + const version = execSync(`sqlite3 "${mprFile}" "select _ProductVersion from _MetaData;"`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }).trim(); + console.log(c.cyan(`Test project was created with Mendix version: ${c.bold(version)}`)); + } catch (error) { + if (error.message.includes("sqlite3") || error.code === "ENOENT") { + console.log(c.gray("sqlite3 command not found, unable to get Mendix version info")); + } else { + console.log(c.gray("Unable to read Mendix version from project file")); + } + } + } + } catch { + console.log(c.gray("Unable to determine Mendix version")); + } + await enquirer.prompt({ type: "confirm", name: "__ignore__", diff --git a/automation/utils/bin/rui-include-oss-in-artifact.ts b/automation/utils/bin/rui-include-oss-in-artifact.ts index c325e80b14..ea00dab908 100755 --- a/automation/utils/bin/rui-include-oss-in-artifact.ts +++ b/automation/utils/bin/rui-include-oss-in-artifact.ts @@ -3,7 +3,7 @@ import { gh } from "../src/github"; import { includeReadmeOssIntoMpk } from "../src/oss-clearance"; import { rm } from "../src/shell"; -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, stat } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import chalk from "chalk"; @@ -48,7 +48,7 @@ async function main(): Promise { throw new Error(`No MPK file found in release '${releaseTag}'`); } - console.log(chalk.green(`āœ… Found MPK: ${mpkAsset.name}`)); + console.log(chalk.green(`āœ… Found MPK: ${mpkAsset.name} (${mpkAsset.size} bytes)`)); // Step 2: Check if HTML file exists if (!htmlAsset) { @@ -57,7 +57,7 @@ async function main(): Promise { return; } - console.log(chalk.green(`āœ… Found HTML: ${htmlAsset.name}`)); + console.log(chalk.green(`āœ… Found HTML: ${htmlAsset.name} (${htmlAsset.size} bytes)`)); // Step 3: Download both files to temp directory console.log(chalk.blue("\nšŸ“„ Downloading artifacts...")); @@ -80,6 +80,11 @@ async function main(): Promise { await includeReadmeOssIntoMpk(htmlPath, mpkPath); console.log(chalk.green("āœ… Merge completed")); + // Get modified MPK size + const modifiedMpkStats = await stat(mpkPath); + const sizeDiff = modifiedMpkStats.size - mpkAsset.size; + console.log(chalk.cyan(`ā„¹ļø Modified MPK size: ${modifiedMpkStats.size} bytes (+${sizeDiff} bytes)`)); + // Step 5: Remove old assets and upload patched MPK console.log(chalk.blue("\nšŸ”„ Replacing assets in release...")); @@ -93,6 +98,7 @@ async function main(): Promise { const newAsset = await gh.uploadReleaseAsset(releaseId, mpkPath, mpkAsset.name); console.log(chalk.green(`āœ… Successfully replaced MPK asset (ID: ${newAsset.id})`)); + console.log(chalk.cyan(`ā„¹ļø New MPK size: ${newAsset.size} bytes`)); // Summary console.log(chalk.bold.green(`\nšŸŽ‰ Process completed successfully!`)); diff --git a/automation/utils/bin/rui-oss-clearance.ts b/automation/utils/bin/rui-oss-clearance.ts index 793e837eab..65b3e0a52f 100755 --- a/automation/utils/bin/rui-oss-clearance.ts +++ b/automation/utils/bin/rui-oss-clearance.ts @@ -12,7 +12,8 @@ import { createSBomGeneratorFolderStructure, findAllReadmeOssLocally, generateSBomArtifactsInFolder, - getRecommendedReadmeOss + getRecommendedReadmeOss, + includeReadmeOssIntoMpk } from "../src/oss-clearance"; // ============================================================================ @@ -55,6 +56,10 @@ function printProgress(message: string): void { console.log(chalk.gray(` → ${message}`)); } +function printProgressCheck(message: string): void { + console.log(chalk.gray(` ā˜‘ ${message}`)); +} + // ============================================================================ // Core Functions // ============================================================================ @@ -84,34 +89,61 @@ async function verifyGitHubAuth(): Promise { async function selectRelease(): Promise { printStep(2, 5, "Fetching draft releases..."); - const releases = await gh.getDraftReleases(); - printSuccess(`Found ${releases.length} draft release${releases.length !== 1 ? "s" : ""}`); + while (true) { + const releases = await gh.getDraftReleases(); + printSuccess(`Found ${releases.length} draft release${releases.length !== 1 ? "s" : ""}`); - if (releases.length === 0) { - printWarning( - "No draft releases found. Please create a draft release before trying again using `prepare-release` tool" - ); - throw new Error("No draft releases found"); - } + if (releases.length === 0) { + printWarning("No draft releases found. Please create a draft release before trying again."); - console.log(); // spacing - const { tag_name } = await prompt<{ tag_name: string }>({ - type: "select", - name: "tag_name", - message: "Select a release to process:", - choices: releases.map(r => ({ - name: r.tag_name, - message: `${r.name} ${chalk.gray(`(${r.tag_name})`)}` - })) - }); + console.log(); // spacing + const { action } = await prompt<{ action: string }>({ + type: "select", + name: "action", + message: "What would you like to do?", + choices: [ + { name: "refresh", message: "--- Refresh the list ---" }, + { name: "exit", message: "āŒ Exit" } + ] + }); - const release = releases.find(r => r.tag_name === tag_name); - if (!release) { - throw new Error(`Release not found: ${tag_name}`); - } + if (action === "exit") { + throw new Error("No draft releases found"); + } + // If "refresh", continue the loop + continue; + } - printInfo(`Selected release: ${chalk.bold(release.name)}`); - return release; + console.log(); // spacing + const { tag_name } = await prompt<{ tag_name: string }>({ + type: "select", + name: "tag_name", + message: "Select a release to process:", + choices: [ + ...releases.map(r => ({ + name: r.tag_name, + message: `${r.name} ${chalk.gray(`(${r.tag_name})`)}` + })), + { + name: "__refresh__", + message: chalk.cyan("--- Refresh the list ---") + } + ] + }); + + if (tag_name === "__refresh__") { + printInfo("Refreshing draft releases list..."); + continue; // Loop again to fetch fresh data + } + + const release = releases.find(r => r.tag_name === tag_name); + if (!release) { + throw new Error(`Release not found: ${tag_name}`); + } + + printInfo(`Selected release: ${chalk.bold(release.name)}`); + return release; + } } async function findAndValidateMpkAsset(release: GitHubDraftRelease): Promise { @@ -135,11 +167,11 @@ async function downloadAndVerifyAsset(mpkAsset: GitHubReleaseAsset, downloadPath printProgress(`Downloading to: ${downloadPath}`); await gh.downloadReleaseAsset(mpkAsset.id, downloadPath); - printSuccess("Download completed"); + printProgressCheck("Download completed"); printProgress("Computing SHA-256 hash..."); const fileHash = await computeHash(downloadPath); - printInfo(`Computed hash: ${fileHash}`); + printProgressCheck(`Computed hash: ${fileHash}`); const expectedDigest = mpkAsset.digest.replace("sha256:", ""); if (fileHash !== expectedDigest) { @@ -149,7 +181,7 @@ async function downloadAndVerifyAsset(mpkAsset: GitHubReleaseAsset, downloadPath throw new Error("Asset integrity verification failed"); } - printSuccess("Hash verification passed"); + printProgressCheck("Hash verification passed"); return fileHash; } @@ -178,41 +210,199 @@ async function computeHash(filepath: string): Promise { // Command Handlers // ============================================================================ -async function handlePrepareCommand(): Promise { +async function selectAction(): Promise<"prepare" | "include"> { + console.log(); // spacing + const { action } = await prompt<{ action: "prepare" | "include" }>({ + type: "select", + name: "action", + message: "What would you like to do with this release?", + choices: [ + { + name: "prepare", + message: "Prepare OSS clearance SBOM" + }, + { + name: "include", + message: "Include OSS Readme" + } + ] + }); + + return action; +} + +async function handlePrepareAction(release: GitHubDraftRelease, mpkAsset: GitHubReleaseAsset): Promise { printHeader("OSS Clearance Artifacts Preparation"); - try { - // Step 1: Verify authentication - await verifyGitHubAuth(); + // Prepare folder structure + const [tmpFolder, downloadPath] = await createSBomGeneratorFolderStructure(release.name); + printInfo(`Working directory: ${tmpFolder}`); - // Step 2: Select release - const release = await selectRelease(); + // Step 4: Download and verify + const fileHash = await downloadAndVerifyAsset(mpkAsset, downloadPath); - // Step 3: Find MPK asset - const mpkAsset = await findAndValidateMpkAsset(release); + // Step 5: Run SBOM Generator + const finalPath = await runSbomGenerator(tmpFolder, release.name, fileHash); - // Prepare folder structure - const [tmpFolder, downloadPath] = await createSBomGeneratorFolderStructure(release.name); - printInfo(`Working directory: ${tmpFolder}`); + console.log(chalk.bold.green(`\nšŸŽ‰ Success! Output file:`)); + console.log(chalk.cyan(` ${finalPath}\n`)); +} - // Step 4: Download and verify - const fileHash = await downloadAndVerifyAsset(mpkAsset, downloadPath); +async function handleIncludeAction(release: GitHubDraftRelease): Promise { + printHeader("OSS Clearance Readme Include"); - // Step 5: Run SBOM Generator - const finalPath = await runSbomGenerator(tmpFolder, release.name, fileHash); + // Step 4: Find and select OSS Readme + const readmes = findAllReadmeOssLocally(); + const recommendedReadmeOss = getRecommendedReadmeOss(release.name, readmes); + + let readmeToInclude: string; + + if (!recommendedReadmeOss) { + const { selectedReadme } = await prompt<{ selectedReadme: string }>({ + type: "select", + name: "selectedReadme", + message: "Select a README_OSS file to include:", + choices: readmes.map(r => ({ + name: r, + message: basename(r) + })) + }); + + readmeToInclude = selectedReadme; + } else { + printSuccess(`Auto selected based on release name:`); + printSuccess(`${chalk.bold(basename(recommendedReadmeOss))}`); + readmeToInclude = recommendedReadmeOss; + } - console.log(chalk.bold.green(`\nšŸŽ‰ Success! Output file:`)); - console.log(chalk.cyan(` ${finalPath}\n`)); - } catch (error) { - console.log("\n" + chalk.bold.red("═".repeat(60))); - printError(`Process failed: ${(error as Error).message}`); - console.log(chalk.bold.red("═".repeat(60)) + "\n"); - process.exit(1); + printInfo(`Readme to include: ${readmeToInclude}`); + + // Step 5: Ask how to include the README + console.log(); // spacing + const { includeMethod } = await prompt<{ includeMethod: "asset" | "embedded" }>({ + type: "select", + name: "includeMethod", + message: "How would you like to include the OSS Readme?", + choices: [ + { + name: "asset", + message: "Upload as separate asset (adds HTML file to release)" + }, + { + name: "embedded", + message: "Embed into MPK (modifies MPK to include HTML inside)" + } + ] + }); + + if (includeMethod === "asset") { + await handleIncludeAsAssetAction(release, readmeToInclude); + } else { + await handleIncludeAsEmbeddedAction(release, readmeToInclude); } } -async function handleIncludeCommand(): Promise { - printHeader("OSS Clearance Readme Include"); +async function handleIncludeAsAssetAction(release: GitHubDraftRelease, readmeToInclude: string): Promise { + printStep(5, 5, "Uploading README as separate asset..."); + + const newAsset = await gh.uploadReleaseAsset(release.id, readmeToInclude, basename(readmeToInclude)); + printSuccess(`Successfully uploaded asset ${newAsset.name} (ID: ${newAsset.id})`); + printInfo(`Size: ${newAsset.size} bytes`); +} + +async function handleIncludeAsEmbeddedAction(release: GitHubDraftRelease, readmeToInclude: string): Promise { + printStep(5, 5, "Embedding README into MPK..."); + + // Find MPK asset + const mpkAsset = release.assets.find(asset => asset.name.endsWith(".mpk")); + if (!mpkAsset) { + printError("No MPK asset found in release"); + throw new Error("MPK asset not found"); + } + + printInfo(`Found MPK: ${mpkAsset.name} (${mpkAsset.size} bytes)`); + + // Create temp folder + const { mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const tmpFolder = await mkdtemp(join(tmpdir(), "mpk-oss-embed-")); + const mpkPath = join(tmpFolder, mpkAsset.name); + const htmlPath = join(tmpFolder, basename(readmeToInclude)); + + try { + // Download MPK to temp folder + printProgress(`Downloading ${mpkAsset.name}...`); + await gh.downloadReleaseAsset(mpkAsset.id, mpkPath); + printProgressCheck("Download completed"); + + // Copy HTML to temp folder + const { cp } = await import("../src/shell"); + await cp(readmeToInclude, htmlPath); + + // Embed HTML into MPK + printProgress("Merging HTML into MPK..."); + await includeReadmeOssIntoMpk(htmlPath, mpkPath); + printProgressCheck("Merge completed"); + + // Get modified MPK size + const { stat } = await import("node:fs/promises"); + const modifiedMpkStats = await stat(mpkPath); + const sizeDiff = modifiedMpkStats.size - mpkAsset.size; + printInfo(`Modified MPK size: ${modifiedMpkStats.size} bytes (+${sizeDiff} bytes)`); + + // Confirm before uploading + console.log(); // spacing + console.log(chalk.yellow("āš ļø This will modify the release assets:")); + console.log( + chalk.gray( + ` 1. Original MPK will be renamed: ${mpkAsset.name} → ${mpkAsset.name.replace(".mpk", "._mpk")}` + ) + ); + console.log(chalk.gray(` 2. Modified MPK will be uploaded: ${mpkAsset.name}`)); + + const { confirmed } = await prompt<{ confirmed: boolean }>({ + type: "confirm", + name: "confirmed", + message: "Do you want to proceed with these changes?", + initial: false + }); + + if (!confirmed) { + printWarning("Operation cancelled by user"); + return; + } + + printProgress("Updating release assets..."); + + // Rename original MPK + const backupName = mpkAsset.name.replace(".mpk", "._mpk"); + printProgress(`Renaming original MPK to ${backupName}...`); + await gh.updateReleaseAsset(mpkAsset.id, backupName); + printProgressCheck("Original MPK renamed"); + + // Upload modified MPK + printProgress(`Uploading modified MPK...`); + const newMpkAsset = await gh.uploadReleaseAsset(release.id, mpkPath, mpkAsset.name); + printProgressCheck(`Modified MPK uploaded (ID: ${newMpkAsset.id})`); + + console.log(chalk.bold.green(`\nšŸŽ‰ Successfully embedded OSS Readme into MPK!`)); + console.log(chalk.gray(` Release: ${release.name}`)); + console.log(chalk.gray(` Modified MPK: ${newMpkAsset.name} (${newMpkAsset.size} bytes)`)); + console.log(chalk.gray(` Backup MPK: ${backupName}`)); + } finally { + // Cleanup temp files + printProgress("Cleaning up temporary files..."); + const { rm } = await import("../src/shell"); + await rm("-rf", tmpFolder); + } +} + +// ============================================================================ +// Main Function +// ============================================================================ + +async function main(): Promise { + printHeader("OSS Clearance Tool"); try { // Step 1: Verify authentication @@ -224,39 +414,15 @@ async function handleIncludeCommand(): Promise { // Step 3: Find MPK asset const mpkAsset = await findAndValidateMpkAsset(release); - // Step 4: Find and select OSS Readme - const readmes = findAllReadmeOssLocally(); - const recommendedReadmeOss = getRecommendedReadmeOss( - release.name.split(" ")[0], - release.name.split(" ")[1], - readmes - ); - - let readmeToInclude: string; + // Step 4: Select action + const action = await selectAction(); - if (!recommendedReadmeOss) { - const { selectedReadme } = await prompt<{ selectedReadme: string }>({ - type: "select", - name: "selectedReadme", - message: "Select a release to process:", - choices: readmes.map(r => ({ - name: r, - message: basename(r) - })) - }); - - readmeToInclude = selectedReadme; + // Step 5: Execute selected action + if (action === "prepare") { + await handlePrepareAction(release, mpkAsset); } else { - readmeToInclude = recommendedReadmeOss; + await handleIncludeAction(release); } - - printInfo(`Readme to include: ${readmeToInclude}`); - - // Step 7: Upload updated asses to the draft release - const newAsset = await gh.uploadReleaseAsset(release.id, readmeToInclude, basename(readmeToInclude)); - console.log(`Successfully uploaded asset ${newAsset.name} (ID: ${newAsset.id})`); - - console.log(release.id); } catch (error) { console.log("\n" + chalk.bold.red("═".repeat(60))); printError(`Process failed: ${(error as Error).message}`); @@ -265,36 +431,6 @@ async function handleIncludeCommand(): Promise { } } -// ============================================================================ -// Main Function -// ============================================================================ - -async function main(): Promise { - const command = process.argv[2]; - - switch (command) { - case "prepare": - await handlePrepareCommand(); - break; - case "include": - await handleIncludeCommand(); - break; - default: - printError(command ? `Unknown command: ${command}` : "No command specified"); - console.log(chalk.white("\nUsage:")); - console.log( - chalk.cyan(" rui-oss-clearance.ts prepare ") + - chalk.gray("- Prepare OSS clearance artifact from draft release") - ); - console.log( - chalk.cyan(" rui-oss-clearance.ts include ") + - chalk.gray("- Include OSS Readme file into a draft release") - ); - console.log(); - process.exit(1); - } -} - // ============================================================================ // Entry Point // ============================================================================ diff --git a/automation/utils/src/github.ts b/automation/utils/src/github.ts index bb3d7f2580..984e39d23f 100644 --- a/automation/utils/src/github.ts +++ b/automation/utils/src/github.ts @@ -339,9 +339,35 @@ export class GitHub { ); } - const asset = (await response.json()) as GitHubReleaseAsset; + return (await response.json()) as GitHubReleaseAsset; + } + + /** + * Update a release asset's name + */ + async updateReleaseAsset(assetId: string, newName: string): Promise { + await this.ensureAuth(); + + const response = await nodefetch( + `https://api.github.com/repos/${this.owner}/${this.repo}/releases/assets/${assetId}`, + { + method: "PATCH", + headers: { + ...this.ghAPIHeaders, + "Content-Type": "application/json" + }, + body: JSON.stringify({ name: newName }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to update asset ${assetId}: ${response.status} ${response.statusText} - ${errorText}` + ); + } - return asset; + return (await response.json()) as GitHubReleaseAsset; } } diff --git a/automation/utils/src/oss-clearance.ts b/automation/utils/src/oss-clearance.ts index c60d49360a..4d96d3e791 100644 --- a/automation/utils/src/oss-clearance.ts +++ b/automation/utils/src/oss-clearance.ts @@ -16,12 +16,14 @@ export function findAllReadmeOssLocally(): string[] { return matchingFiles1.concat(matchingFiles2); } -export function getRecommendedReadmeOss( - packageName: string, - packageVersion: string, - availableReadmes: string[] -): string | undefined { - const fileNames = availableReadmes.map(r => [basename(r), r]); +export function getRecommendedReadmeOss(packageNameAndVersion: string, availableReadmes: string[]): string | undefined { + const fileNames = availableReadmes.map(r => [basename(r).toLowerCase(), r]); + + const nameParts = packageNameAndVersion.split(" "); + const version = nameParts.pop()!; + + const packageName = nameParts.join("").toLowerCase(); + const packageVersion = version.replace("v", ""); return fileNames.find(([name]) => name.includes(packageName) && name.includes(packageVersion))?.at(1); }