Files
jerapah-flow/packages/server/plugin-install.js
nsrb 6c82ff20eb fix(plugins): enhance plugin installation and script handling
- Updated the `pnpm install` command in `plugin-install.js` to include the `--ignore-workspace` flag, ensuring proper installation of plugins within the app tree.
- Improved the `instantiateScriptSource` function in `script-sandbox.js` to resolve `pluginDir` more effectively, allowing for better package management.
- Added a new smoke test in `plugins-smoke.js` to validate the ability to require additional packages from plugin directories, enhancing testing coverage for plugin functionality.
- Updated documentation in `AGENTS.md` to reflect changes in the installation command.
2026-09-08 22:00:26 +07:00

225 lines
6.5 KiB
JavaScript

import fs from "fs";
import os from "os";
import path from "path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { EXAMPLE_PLUGINS_DIR, PLUGINS_DIR } from "./paths.js";
import {
installPluginFromDirectory,
pluginDir,
} from "./plugin-store.js";
const execFileAsync = promisify(execFile);
/**
* @param {string} url
*/
export function assertHttpsGitUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
const err = new Error("invalid git URL");
err.statusCode = 400;
throw err;
}
if (parsed.protocol !== "https:") {
const err = new Error("git URL must use https://");
err.statusCode = 400;
throw err;
}
return parsed.toString();
}
/**
* Run pnpm install in a plugin directory (ignore lifecycle scripts).
* @param {string} dir
*/
export async function pnpmInstallPlugin(dir) {
const pkg = path.join(dir, "package.json");
if (!fs.existsSync(pkg)) return { skipped: true };
let hasDeps = false;
try {
const raw = JSON.parse(fs.readFileSync(pkg, "utf8"));
hasDeps = Boolean(
(raw.dependencies && Object.keys(raw.dependencies).length) ||
(raw.optionalDependencies &&
Object.keys(raw.optionalDependencies).length),
);
} catch {
hasDeps = true;
}
if (!hasDeps) return { skipped: true };
// --ignore-workspace: plugins live under the app tree but are not workspace
// packages; without this, pnpm install --dir can no-op against the root monorepo.
await execFileAsync(
"pnpm",
[
"install",
"--dir",
dir,
"--ignore-scripts",
"--prefer-offline",
"--ignore-workspace",
],
{
cwd: dir,
env: { ...process.env, CI: "1" },
timeout: 5 * 60_000,
maxBuffer: 10 * 1024 * 1024,
},
);
return { skipped: false };
}
/**
* @param {string} url
* @param {{ ref?: string, overwrite?: boolean }} [opts]
*/
export async function installPluginFromGit(url, opts = {}) {
const httpsUrl = assertHttpsGitUrl(url);
const staging = path.join(
PLUGINS_DIR,
`.staging-git-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
fs.mkdirSync(staging, { recursive: true });
try {
const args = ["clone", "--depth", "1"];
if (opts.ref) {
args.push("--branch", String(opts.ref));
}
args.push(httpsUrl, staging);
await execFileAsync("git", args, {
timeout: 5 * 60_000,
maxBuffer: 5 * 1024 * 1024,
});
// Remove .git to keep plugins lean
fs.rmSync(path.join(staging, ".git"), { recursive: true, force: true });
const installed = installPluginFromDirectory(staging, {
overwrite: Boolean(opts.overwrite),
markRestart: false,
});
await pnpmInstallPlugin(installed.dir);
const { bumpGeneration } = await import("./control-state.js");
bumpGeneration(`plugin:${installed.id} installed from git`);
return installed;
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
/**
* @param {string} zipPath
* @param {{ overwrite?: boolean }} [opts]
*/
export async function installPluginFromZipFile(zipPath, opts = {}) {
const abs = path.resolve(zipPath);
if (!fs.existsSync(abs)) {
const err = new Error("zip file not found");
err.statusCode = 400;
throw err;
}
const staging = path.join(
PLUGINS_DIR,
`.staging-zip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const extractDir = path.join(staging, "extract");
fs.mkdirSync(extractDir, { recursive: true });
try {
await execFileAsync("unzip", ["-q", abs, "-d", extractDir], {
timeout: 120_000,
});
const root = findPluginRoot(extractDir);
const installed = installPluginFromDirectory(root, {
overwrite: Boolean(opts.overwrite),
markRestart: false,
});
await pnpmInstallPlugin(installed.dir);
const { bumpGeneration } = await import("./control-state.js");
bumpGeneration(`plugin:${installed.id} installed from zip`);
return installed;
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
/**
* @param {Buffer} buffer
* @param {{ overwrite?: boolean }} [opts]
*/
export async function installPluginFromZipBuffer(buffer, opts = {}) {
const tmp = path.join(
os.tmpdir(),
`jflow-plugin-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
);
fs.writeFileSync(tmp, buffer);
try {
return await installPluginFromZipFile(tmp, opts);
} finally {
fs.unlinkSync(tmp);
}
}
/**
* Find directory containing jerapah-plugin.json (zip may have a single top folder).
* @param {string} extractDir
*/
function findPluginRoot(extractDir) {
const direct = path.join(extractDir, "jerapah-plugin.json");
if (fs.existsSync(direct)) return extractDir;
const entries = fs.readdirSync(extractDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
if (dirs.length === 1) {
const nested = path.join(extractDir, dirs[0].name);
if (fs.existsSync(path.join(nested, "jerapah-plugin.json"))) return nested;
}
for (const e of dirs) {
const nested = path.join(extractDir, e.name);
if (fs.existsSync(path.join(nested, "jerapah-plugin.json"))) return nested;
}
const err = new Error("zip missing jerapah-plugin.json");
err.statusCode = 400;
throw err;
}
/**
* Install a shipped example plugin by id (from examples/plugins/<id>).
* @param {string} exampleId
* @param {{ overwrite?: boolean }} [opts]
*/
export async function installExamplePlugin(exampleId, opts = {}) {
const src = path.join(EXAMPLE_PLUGINS_DIR, exampleId);
if (!fs.existsSync(src)) {
const err = new Error(`example plugin not found: ${exampleId}`);
err.statusCode = 404;
throw err;
}
const installed = installPluginFromDirectory(src, {
overwrite: Boolean(opts.overwrite),
markRestart: false,
});
await pnpmInstallPlugin(installed.dir);
const { bumpGeneration } = await import("./control-state.js");
bumpGeneration(`plugin:${installed.id} installed from example`);
return installed;
}
/**
* Copy example into plugins if missing (used by smoke / first-run helpers).
* @param {string} exampleId
*/
export function ensureExampleInstalled(exampleId) {
const dest = pluginDir(exampleId);
if (fs.existsSync(dest)) {
return { id: exampleId, already: true, dir: dest };
}
const src = path.join(EXAMPLE_PLUGINS_DIR, exampleId);
const installed = installPluginFromDirectory(src, {
overwrite: false,
markRestart: false,
});
return { ...installed, already: false };
}