UNPKG

@pagopa/dx-cli

Version:

A CLI useful to manage DX tools.

215 lines (214 loc) 8.5 kB
import { getLogger } from "@logtape/logtape"; import { $ } from "execa"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import { replaceInFile } from "replace-in-file"; import semver from "semver"; import YAML from "yaml"; import { getLatestCommitShaOrRef } from "./git.js"; import { updateJSCodeReviewJob } from "./update-code-review.js"; import { migrateWorkflow } from "./use-azure-appsvc.js"; export class NPM { lockFileName = "package-lock.json"; async listWorkspaces() { const { stdout } = await $ `npm query .workspace`; const workspaces = JSON.parse(stdout); const workspaceNames = []; if (Array.isArray(workspaces)) { for (const ws of workspaces) { if (Object.hasOwn(ws, "name")) { workspaceNames.push(ws.name); } } } return workspaceNames; } } export class Yarn { lockFileName = "yarn.lock"; async listWorkspaces() { const { stdout } = await $({ lines: true }) `yarn workspaces list --json`; const workspaceNames = []; for (const line of stdout) { const ws = JSON.parse(line); if (Object.hasOwn(ws, "name")) { workspaceNames.push(ws.name); } } return workspaceNames; } } export async function extractPackageExtensions() { // Read the .yarnrc.yaml file if it exists and extract the packageExtensions field try { const yarnrc = await fs.readFile(".yarnrc.yml", "utf-8"); const parsed = YAML.parse(yarnrc); if (parsed.packageExtensions) { return parsed.packageExtensions; } } catch { // File does not exist or is not readable, ignore } return undefined; } export async function preparePackageJsonForPnpm() { const packageJson = await fs.readFile("package.json", "utf-8"); const manifest = JSON.parse(packageJson); let workspaces = []; if (Object.hasOwn(manifest, "packageManager")) { delete manifest.packageManager; } if (Object.hasOwn(manifest, "workspaces")) { if (Array.isArray(manifest.workspaces)) { workspaces = manifest.workspaces; } delete manifest.workspaces; } await fs.writeFile("package.json", JSON.stringify(manifest, null, 2)); return workspaces; } export async function writePnpmWorkspaceFile(workspaces, packageExtensions) { // We inline all the default settings here because Renovate // does not support PNPM's config dependencies yet. const pnpmWorkspace = { cleanupUnusedCatalogs: true, linkWorkspacePackages: true, packageExtensions, packageImportMethod: "clone-or-copy", packages: workspaces.length > 0 ? workspaces : ["apps/*", "packages/*"], }; const yamlContent = YAML.stringify(pnpmWorkspace); await fs.writeFile("pnpm-workspace.yaml", yamlContent, "utf-8"); } async function removeFiles(...files) { await Promise.all(files.map((file) => // Remove the file if it exists, fail silently if it doesn't. fs.rm(file, { force: true, recursive: true }).catch(() => undefined))); } async function replacePMOccurrences() { const logger = getLogger(["dx-cli", "codemod"]); logger.info("Replacing yarn and npm occurrences in files..."); const results = await replaceInFile({ allowEmptyPaths: true, files: ["**/*.json", "**/*.md", "**/Dockerfile", "**/docker-compose.yml"], from: [ "https://yarnpkg.com/", "https://classic.yarnpkg.com/", /\b(yarn workspace|npm -(\b-workspace\b|\bw\b)) (\S+)\b/g, /\b(yarn workspace|npm -(\b-workspace\b|\bw\b))\b/g, /\b(yarn install --immutable|npm ci)\b/g, /\b(yarn -q dlx|npx)\b/g, /\b((yarn|npm) run)\b/g, /(^|\s|")(yarn|npm)(?!\S)/gi, ], ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"], to: [ "https://pnpm.io/", "https://pnpm.io/", "pnpm --filter $3", "pnpm --filter <package-selector>", "pnpm install --frozen-lockfile", "pnpm dlx", "pnpm run", "$1pnpm", ], }); const count = results.reduce((acc, file) => (file.hasChanged ? acc + 1 : acc), 0); logger.info("Replaced yarn occurrences in {count} files", { count }); } async function updateDXWorkflows() { const logger = getLogger(["dx-cli", "codemod"]); logger.info("Updating Github Workflows workflows..."); // Get the latest commit sha from the main branch of the dx repository const sha = await getLatestCommitShaOrRef("pagopa", "dx"); // Update the js_code_review workflow to use the latest commit sha const results = await replaceInFile({ allowEmptyPaths: true, files: [".github/workflows/*.yaml", ".github/workflows/*.yml"], processor: updateJSCodeReviewJob(sha), }); const ignore = results.filter((r) => r.hasChanged).map((r) => r.file); // Update the legacy deployment workflow to release-azure-appsvc-v1.yaml await replaceInFile({ allowEmptyPaths: true, files: [".github/workflows/*.yaml", ".github/workflows/*.yml"], ignore, processor: migrateWorkflow(sha), }); } export const usePnpm = async (packageManager, currentNodeVersion) => { const minNodeVersion = "20.19.5"; assert.notEqual(packageManager, "pnpm", "Project is already using pnpm"); assert.ok(semver.gte(currentNodeVersion, minNodeVersion), `his codemod requires Node.js >= ${minNodeVersion}. Current version: ${currentNodeVersion}`); const logger = getLogger(["dx-cli", "codemod"]); const pm = packageManager === "yarn" ? new Yarn() : new NPM(); const localWorkspaces = await pm.listWorkspaces(); // Update local dependencies to use "workspace:" protocol if (localWorkspaces.length > 0) { logger.info("Using the {protocol} protocol for local dependencies", { protocol: "workspace:", }); await replaceInFile({ allowEmptyPaths: true, files: ["**/package.json"], from: localWorkspaces.map((ws) => new RegExp(`"${ws}": ".*?"`, "g")), to: localWorkspaces.map((ws) => `"${ws}": "workspace:^"`), }); } // Remove unused field from package.json logger.info("Remove unused fields from {file}", { file: "package.json", }); const workspaces = await preparePackageJsonForPnpm(); // Extract custom packageExtensions from .yarnrc.yml if any const packageExtensions = packageManager === "yarn" ? await extractPackageExtensions() : undefined; // Create pnpm-workspace.yaml logger.info("Create {file}", { file: "pnpm-workspace.yaml", }); await writePnpmWorkspaceFile(workspaces, packageExtensions); // Remove yarn and node_modules files and folders logger.info("Remove node_modules and yarn files"); await removeFiles(".yarnrc", ".yarnrc.yml", "yarn.config.cjs", ".yarn", ".pnp.cjs", ".pnp.loader.cjs", "node_modules"); // Import lockfile const stat = await fs.stat(pm.lockFileName); if (stat.isFile()) { logger.info("Importing {source} to {target}", { source: pm.lockFileName, target: "pnpm-lock.yaml", }); await $ `corepack pnpm@latest import ${pm.lockFileName}`; await removeFiles(pm.lockFileName); } else { logger.info("No {source} file found, skipping import.", { source: pm.lockFileName, }); } // Replace yarn and npm occurrences in files and update workflows await replacePMOccurrences(); await updateDXWorkflows(); // Add pnpm store to .gitignore logger.info("Adding pnpm store to .gitignore..."); await fs.appendFile(".gitignore", "\n\n# PNPM\n.pnpm-store"); // Set pnpm as the package manager logger.info("Setting pnpm as the package manager..."); await $ `corepack use pnpm@latest`; }; const apply = async (info) => { const logger = getLogger(["dx-cli", "codemod"]); try { await usePnpm(info.packageManager, process.versions.node); } catch (error) { if (error instanceof Error) { logger.error(error.message); } } }; export default { apply, description: "Migrate the project to use pnpm as the package manager", id: "use-pnpm", };