/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as rpc from "vscode-jsonrpc/node";
import * as path from "path";
import * as fs from "fs";
import * as os from "os";
import {spawn} from "child_process";
import {withMetrics} from "./metrics";
import {RecipeMarketplace} from "../../marketplace";
import {getPlatformCommand} from "../../shell-utils";
export interface InstallRecipesResponse {
recipesInstalled: number
version?: string
}
/**
* Helper function to spawn npm/npx commands with logging and promise handling
*/
async function spawnNpmCommand(
command: string,
args: string[],
cwd: string,
logger?: rpc.Logger,
logPrefix?: string
): Promise {
const child = spawn(getPlatformCommand(command), args, {cwd, ...(os.platform() === 'win32' ? { shell: true } : {})});
if (logger) {
const prefix = logPrefix ? `${logPrefix}: ` : '';
child.stdout.on("data", (data: any) => {
logger.info(`${prefix}${data.toString().trim()}`);
});
child.stderr.on("data", (data: any) => {
logger.error(`${prefix}${data.toString().trim()}`);
});
}
return new Promise((resolve, reject) => {
child.on("error", reject);
child.on("close", (exitCode: number) => {
if (exitCode === 0) {
resolve();
} else {
const commandStr = `${command} ${args.join(' ')}`;
reject(new Error(`${commandStr} exited with code ${exitCode}`));
}
});
});
}
export class InstallRecipes {
/**
* Install a recipe from a local path or an NPM package.
* @param recipes The path to a file on disk that provides an activate function or the
* name of an NPM package.
*/
constructor(private readonly recipes: string | { packageName: string, version?: string }) {
}
static handle(connection: rpc.MessageConnection, installDir: string, marketplace: RecipeMarketplace,
recipeOrigin: Map, logger?: rpc.Logger, metricsCsv?: string): void {
connection.onRequest(
new rpc.RequestType("InstallRecipes"),
withMetrics(
"InstallRecipes",
metricsCsv,
(context) => async (request) => {
context.target = typeof request.recipes === "object" ? request.recipes.packageName : request.recipes;
const beforeNames = new Set(marketplace.allRecipes().map(r => r.name));
let resolvedPath;
let recipesName = request.recipes;
let installedVersion: string | undefined;
if (typeof request.recipes === "object") {
const recipePackage = request.recipes;
const absoluteInstallDir = path.isAbsolute(installDir) ? installDir : path.join(process.cwd(), installDir);
// Ensure directory exists
if (!fs.existsSync(absoluteInstallDir)) {
fs.mkdirSync(absoluteInstallDir, {recursive: true});
}
// Check if package.json exists, if not create one
const packageJsonPath = path.join(absoluteInstallDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
// Create a minimal package.json with a custom name
const packageJson = {
name: "openrewrite-recipes",
version: "1.0.0",
description: "OpenRewrite recipe marketplace",
private: true
};
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
if (logger) {
logger.info("Created package.json for recipe dependencies");
}
}
// Rather than using npm on PATH, use `node_cli.js`.
// https://stackoverflow.com/questions/15957529/can-i-install-a-npm-package-from-javascript-running-in-node-js
const packageSpec = recipePackage.packageName + (recipePackage.version ? `@${recipePackage.version}` : "@latest");
await spawnNpmCommand("npm", ["install", packageSpec, "--no-fund"], absoluteInstallDir, logger);
resolvedPath = require.resolve(path.join(absoluteInstallDir, "node_modules", recipePackage.packageName));
recipesName = request.recipes.packageName;
// Read the installed package's version from its package.json
const installedPackageJsonPath = path.join(absoluteInstallDir, "node_modules", recipePackage.packageName, "package.json");
if (fs.existsSync(installedPackageJsonPath)) {
const installedPackageJson = JSON.parse(fs.readFileSync(installedPackageJsonPath, "utf8"));
installedVersion = installedPackageJson.version;
}
} else {
resolvedPath = request.recipes;
}
let recipeModule;
try {
// Pre-load core modules that are used by recipes but loaded lazily
// This ensures they're in require.cache before setupSharedDependencies runs
preloadCoreModules(logger);
setupSharedDependencies(resolvedPath, logger);
recipeModule = require(resolvedPath);
} catch (e: any) {
throw new Error(`Failed to load recipe module from ${resolvedPath}: ${e.stack}`);
}
if (typeof recipeModule.activate === "function") {
// noinspection JSVoidFunctionReturnValueUsed
const activatePromise = recipeModule.activate(marketplace);
// noinspection SuspiciousTypeOfGuard
if (activatePromise instanceof Promise) {
await activatePromise;
}
} else {
throw new Error(`${recipesName} does not export an 'activate' function`);
}
const origin = typeof request.recipes === "object"
? request.recipes.packageName
: request.recipes;
for (const recipe of marketplace.allRecipes()) {
if (!beforeNames.has(recipe.name)) {
recipeOrigin.set(recipe.name, origin);
}
}
return {
recipesInstalled: marketplace.allRecipes().length - beforeNames.size,
version: installedVersion
};
}
)
);
}
}
/**
* Pre-loads core modules that are typically loaded lazily by recipes.
* This ensures they're in require.cache before setupSharedDependencies runs,
* so they can be properly mapped to avoid instanceof failures.
*/
function preloadCoreModules(logger?: rpc.Logger) {
const modulesToPreload = [
'../..',
'../../java',
'../../javascript',
'../../json',
'../../rpc',
'../../search',
'../../text',
];
modulesToPreload.forEach(modulePath => {
try {
require(modulePath);
if (logger) {
logger.info(`[preloadCoreModules] Loaded ${modulePath}`);
}
} catch (e) {
if (logger) {
logger.warn(`[preloadCoreModules] Failed to load ${modulePath}: ${e}`);
}
}
});
}
/**
* Ensures dynamically loaded modules share the same class instances as the host
* by mapping require.cache entries. This prevents instanceof failures when the
* same package is installed in multiple node_modules directories.
*/
function setupSharedDependencies(targetModulePath: string, logger?: rpc.Logger) {
const sharedDeps = ['@openrewrite/rewrite', 'vscode-jsonrpc', 'mutative'];
// For local-path installs, targetModulePath is the package dir itself.
let targetDir: string;
try {
targetDir = fs.statSync(targetModulePath).isDirectory()
? targetModulePath
: path.dirname(targetModulePath);
} catch {
targetDir = path.dirname(targetModulePath);
}
sharedDeps.forEach(depName => {
try {
// Step 1: Find where this package is currently loaded from (host)
const hostPackageEntry = require.resolve(depName);
// Step 2: Find the package root by looking for package.json
let hostPackageRoot = path.dirname(hostPackageEntry);
while (hostPackageRoot !== path.dirname(hostPackageRoot)) {
const packageJsonPath = path.join(hostPackageRoot, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (pkg.name === depName) {
break; // Found the package root
}
} catch (e) {
// Not a valid package.json, continue
}
}
hostPackageRoot = path.dirname(hostPackageRoot);
}
if (logger) {
logger.info(`[setupSharedDependencies] Host package root for ${depName}: ${hostPackageRoot}`);
}
// Step 3: Find where the target's node_modules has this package
// We explicitly look in node_modules to avoid finding npm-linked global packages
let targetPackageRoot: string | undefined;
// Walk up from targetDir looking for node_modules containing this package
let searchDir = targetDir;
while (searchDir !== path.dirname(searchDir)) {
const nodeModulesPath = path.join(searchDir, 'node_modules', ...depName.split('/'));
if (fs.existsSync(nodeModulesPath)) {
const packageJsonPath = path.join(nodeModulesPath, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (pkg.name === depName) {
targetPackageRoot = nodeModulesPath;
break;
}
} catch (e) {
// Not a valid package.json, continue
}
}
}
searchDir = path.dirname(searchDir);
}
if (!targetPackageRoot) {
if (logger) {
logger.warn(`[setupSharedDependencies] Could not find ${depName} in target's node_modules`);
}
return; // Can't map this package
}
if (logger) {
logger.info(`[setupSharedDependencies] Target package root for ${depName}: ${targetPackageRoot}`);
}
// If they're the same, no mapping needed
if (hostPackageRoot === targetPackageRoot) {
if (logger) {
logger.info(`[setupSharedDependencies] Same package root, no mapping needed for ${depName}`);
}
return;
}
// Step 4: Map all cached modules from host package to target package
const hostPrefix = hostPackageRoot + path.sep;
let mappedCount = 0;
for (const cachedPath of Object.keys(require.cache)) {
if (cachedPath.startsWith(hostPrefix)) {
// This module belongs to the host package
const relativePath = cachedPath.substring(hostPrefix.length);
const targetPath = path.join(targetPackageRoot, relativePath);
// Map the target path to use the host's cached module
require.cache[targetPath] = require.cache[cachedPath];
mappedCount++;
}
}
if (logger) {
logger.info(`[setupSharedDependencies] Mapped ${mappedCount} modules for ${depName}`);
}
} catch (e) {
if (logger) {
logger.error(`[setupSharedDependencies] Failed to setup ${depName}: ${e}`);
}
}
});
}