mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip runs across the CLI, server, adapters, plugins, CI, and container images. > - These surfaces declared different Node.js versions from 20 through 24. > - A newer `@types/node` major can expose APIs that the supported runtime does not provide. > - Node.js 20 is no longer a suitable project baseline, and Node.js 24 is the current LTS line. > - This pull request sets Node.js 24.11.0 as one repository-wide baseline, adds a drift check, and gives users actionable startup guidance when their runtime is too old. > - The benefit is one clear runtime contract for development, release, installation, and published packages. ## Linked Issues or Issue Description Refs #2734 Refs #11727 Refs #739 ## What Changed - Require Node.js 24.11.0 or newer in all 42 package manifests and runtime checks. - Use Node.js 24 in GitHub Actions, Docker images, smoke images, sandbox setup, portable installs, and esbuild targets. - Align every direct `@types/node` declaration on `^24.0.0`. - Prevent Dependabot from opening major `@types/node` upgrades without a matching runtime decision. - Add `.nvmrc` and a CI policy check for Node version drift. - Update ACP version gates, tests, and user documentation for the new minimum. - Print a non-blocking warning on CLI and server startup when Node is unsupported, with remediation through a version manager or the documented downloaded `install.sh` workflow. - Deduplicate that warning when `paperclipai run` boots the CLI and server in the same process. ## Verification - `node scripts/check-node-version-policy.mjs` - `node --check scripts/check-node-version-policy.mjs` - `node --check cli/esbuild.config.mjs` - `node --check scripts/generate-npm-package-json.mjs` - `bash -n scripts/install.sh scripts/test-install-sh-docker.sh scripts/e2e-install-lifecycle.sh` - Parsed all 42 package manifests and confirmed `engines.node` is `>=24.11.0`. - `git diff --check` - `vitest run packages/adapter-utils/src/sandbox-install-command.test.ts` passed with 3 tests. - `vitest run cli/src/node-version.test.ts` passed with 4 tests. - Directly exercised the shared warning helper for unsupported-version messaging and same-process deduplication. - The focused exe.dev suite could not resolve the locally unbuilt plugin SDK from this isolated worktree. A full offline workspace install was also blocked because the package-manager signature verifier requires registry access. The full suite was not run locally; draft CI performs a clean install and evaluates the wider impact. ## Risks - This is a breaking runtime change for users, plugins, and deployments that still use Node.js 20 or 22. - Published workspace packages will now produce an engine warning or failure in strict package managers on older Node.js releases. - Node.js 24 can reveal dependency, native module, Playwright, or agent CLI compatibility issues in CI. - The bootstrap installer now installs Node.js 24 when the current runtime is older than 24.11.0. - The portable sandbox fallback is pinned to Node.js 24.11.0 and depends on that upstream tarball remaining available. - Unsupported runtimes continue booting after a warning, so a later incompatibility can still fail at its point of use. - The CLI and server share the warning policy through the published `@paperclipai/shared` package; packaging checks must keep that subpath export available. - This PR does not commit `pnpm-lock.yaml` because repository policy assigns lockfile generation to CI. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex based on GPT-5. The exact deployment ID and context window are not exposed in this session. Reasoning, repository tools, shell execution, and GitHub tools were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
701 lines
21 KiB
TypeScript
701 lines
21 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const VALID_TEMPLATES = ["default", "connector", "workspace", "environment"] as const;
|
|
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
|
const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui", "environment"] as const);
|
|
|
|
export interface ScaffoldPluginOptions {
|
|
pluginName: string;
|
|
outputDir: string;
|
|
template?: PluginTemplate;
|
|
displayName?: string;
|
|
description?: string;
|
|
author?: string;
|
|
category?: "connector" | "workspace" | "automation" | "ui" | "environment";
|
|
sdkPath?: string;
|
|
}
|
|
|
|
/** Validate npm-style plugin package names (scoped or unscoped). */
|
|
export function isValidPluginName(name: string): boolean {
|
|
const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/;
|
|
const unscopedPattern = /^[a-z0-9._-]+$/;
|
|
return scopedPattern.test(name) || unscopedPattern.test(name);
|
|
}
|
|
|
|
/** Convert `@scope/name` to an output directory basename (`name`). */
|
|
function packageToDirName(pluginName: string): string {
|
|
return pluginName.replace(/^@[^/]+\//, "");
|
|
}
|
|
|
|
/** Convert an npm package name into a manifest-safe plugin id. */
|
|
function packageToManifestId(pluginName: string): string {
|
|
if (!pluginName.startsWith("@")) {
|
|
return pluginName;
|
|
}
|
|
|
|
return pluginName.slice(1).replace("/", ".");
|
|
}
|
|
|
|
/** Build a human-readable display name from package name tokens. */
|
|
function makeDisplayName(pluginName: string): string {
|
|
const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim();
|
|
return raw
|
|
.split(/\s+/)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function writeFile(target: string, content: string) {
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, content);
|
|
}
|
|
|
|
function quote(value: string): string {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function toPosixPath(value: string): string {
|
|
return value.split(path.sep).join("/");
|
|
}
|
|
|
|
export function shellQuote(value: string): string {
|
|
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value;
|
|
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
|
}
|
|
|
|
function formatFileDependency(absPath: string): string {
|
|
return `file:${toPosixPath(path.resolve(absPath))}`;
|
|
}
|
|
|
|
function getLocalSdkPackagePath(): string {
|
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk");
|
|
}
|
|
|
|
function getRepoRootFromSdkPath(sdkPath: string): string {
|
|
return path.resolve(sdkPath, "..", "..", "..");
|
|
}
|
|
|
|
function getLocalSharedPackagePath(sdkPath: string): string {
|
|
return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared");
|
|
}
|
|
|
|
function isInsideDir(targetPath: string, parentPath: string): boolean {
|
|
const relative = path.relative(parentPath, targetPath);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function packLocalPackage(packagePath: string, outputDir: string): string {
|
|
const packageJsonPath = path.join(packagePath, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
throw new Error(`Package package.json not found at ${packageJsonPath}`);
|
|
}
|
|
|
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
|
name?: string;
|
|
version?: string;
|
|
};
|
|
const packageName = packageJson.name ?? path.basename(packagePath);
|
|
const packageVersion = packageJson.version ?? "0.0.0";
|
|
const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`;
|
|
const sdkBundleDir = path.join(outputDir, ".paperclip-sdk");
|
|
|
|
fs.mkdirSync(sdkBundleDir, { recursive: true });
|
|
execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" });
|
|
execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" });
|
|
|
|
const tarballPath = path.join(sdkBundleDir, tarballFileName);
|
|
if (!fs.existsSync(tarballPath)) {
|
|
throw new Error(`Packed tarball was not created at ${tarballPath}`);
|
|
}
|
|
|
|
return tarballPath;
|
|
}
|
|
|
|
/**
|
|
* Generate a complete Paperclip plugin starter project.
|
|
*
|
|
* Output includes manifest/worker/UI entries, SDK harness tests, bundler presets,
|
|
* and a local dev server script for hot-reload workflow.
|
|
*/
|
|
export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
|
const template = options.template ?? "default";
|
|
if (!VALID_TEMPLATES.includes(template)) {
|
|
throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`);
|
|
}
|
|
|
|
if (!isValidPluginName(options.pluginName)) {
|
|
throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens.");
|
|
}
|
|
|
|
if (options.category && !VALID_CATEGORIES.has(options.category)) {
|
|
throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`);
|
|
}
|
|
|
|
const outputDir = path.resolve(options.outputDir);
|
|
if (fs.existsSync(outputDir)) {
|
|
throw new Error(`Directory already exists: ${outputDir}`);
|
|
}
|
|
|
|
const displayName = options.displayName ?? makeDisplayName(options.pluginName);
|
|
const description = options.description ?? "A Paperclip plugin";
|
|
const author = options.author ?? "Plugin Author";
|
|
const category = options.category ?? (template === "workspace" ? "workspace" : template === "environment" ? "environment" : "connector");
|
|
const manifestId = packageToManifestId(options.pluginName);
|
|
const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath());
|
|
const localSharedPath = getLocalSharedPackagePath(localSdkPath);
|
|
const repoRoot = getRepoRootFromSdkPath(localSdkPath);
|
|
const useWorkspaceSdk = isInsideDir(outputDir, repoRoot);
|
|
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir);
|
|
const sdkDependency = useWorkspaceSdk
|
|
? "workspace:*"
|
|
: `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`;
|
|
|
|
const packageJson = {
|
|
name: options.pluginName,
|
|
version: "0.1.0",
|
|
type: "module",
|
|
private: true,
|
|
description,
|
|
scripts: {
|
|
build: "node ./esbuild.config.mjs",
|
|
"build:rollup": "rollup -c",
|
|
dev: "node ./esbuild.config.mjs --watch",
|
|
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
|
test: "vitest run --config ./vitest.config.ts",
|
|
typecheck: "tsc --noEmit"
|
|
},
|
|
paperclipPlugin: {
|
|
manifest: "./dist/manifest.js",
|
|
worker: "./dist/worker.js",
|
|
ui: "./dist/ui/"
|
|
},
|
|
keywords: ["paperclip", "plugin", category],
|
|
author,
|
|
license: "MIT",
|
|
...(packedSharedTarball
|
|
? {
|
|
pnpm: {
|
|
overrides: {
|
|
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
devDependencies: {
|
|
...(packedSharedTarball
|
|
? {
|
|
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
|
}
|
|
: {}),
|
|
"@paperclipai/plugin-sdk": sdkDependency,
|
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
|
"@rollup/plugin-typescript": "^12.1.2",
|
|
"@types/node": "^24.0.0",
|
|
"@types/react": "^19.0.8",
|
|
esbuild: "^0.27.3",
|
|
rollup: "^4.38.0",
|
|
tslib: "^2.8.1",
|
|
typescript: "^5.7.3",
|
|
vitest: "^3.0.5"
|
|
},
|
|
peerDependencies: {
|
|
react: ">=18"
|
|
}
|
|
};
|
|
|
|
writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
|
|
const tsconfig = {
|
|
compilerOptions: {
|
|
target: "ES2022",
|
|
module: "NodeNext",
|
|
moduleResolution: "NodeNext",
|
|
lib: ["ES2022", "DOM"],
|
|
jsx: "react-jsx",
|
|
strict: true,
|
|
skipLibCheck: true,
|
|
declaration: true,
|
|
declarationMap: true,
|
|
sourceMap: true,
|
|
outDir: "dist",
|
|
rootDir: "."
|
|
},
|
|
include: ["src", "tests"],
|
|
exclude: ["dist", "node_modules"]
|
|
};
|
|
|
|
writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "esbuild.config.mjs"),
|
|
`import esbuild from "esbuild";
|
|
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
|
|
|
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
|
const watch = process.argv.includes("--watch");
|
|
|
|
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
|
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
|
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
|
|
|
if (watch) {
|
|
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
|
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
|
} else {
|
|
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
|
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
|
}
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "rollup.config.mjs"),
|
|
`import { nodeResolve } from "@rollup/plugin-node-resolve";
|
|
import typescript from "@rollup/plugin-typescript";
|
|
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
|
|
|
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
|
|
|
function withPlugins(config) {
|
|
if (!config) return null;
|
|
return {
|
|
...config,
|
|
plugins: [
|
|
nodeResolve({
|
|
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
|
}),
|
|
typescript({
|
|
tsconfig: "./tsconfig.json",
|
|
declaration: false,
|
|
declarationMap: false,
|
|
}),
|
|
],
|
|
};
|
|
}
|
|
|
|
export default [
|
|
withPlugins(presets.rollup.manifest),
|
|
withPlugins(presets.rollup.worker),
|
|
withPlugins(presets.rollup.ui),
|
|
].filter(Boolean);
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "vitest.config.ts"),
|
|
`import { defineConfig } from "vitest/config";
|
|
|
|
export default defineConfig({
|
|
test: {
|
|
include: ["tests/**/*.spec.ts"],
|
|
environment: "node",
|
|
},
|
|
});
|
|
`,
|
|
);
|
|
|
|
if (template === "environment") {
|
|
writeFile(
|
|
path.join(outputDir, "src", "manifest.ts"),
|
|
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
|
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: ${quote(manifestId)},
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: ${quote(displayName)},
|
|
description: ${quote(description)},
|
|
author: ${quote(author)},
|
|
categories: [${quote(category)}],
|
|
capabilities: [
|
|
"environment.drivers.register",
|
|
"plugin.state.read",
|
|
"plugin.state.write",
|
|
"ui.dashboardWidget.register"
|
|
],
|
|
entrypoints: {
|
|
worker: "./dist/worker.js",
|
|
ui: "./dist/ui"
|
|
},
|
|
environmentDrivers: [
|
|
{
|
|
driverKey: ${quote(manifestId + "-driver")},
|
|
displayName: ${quote(displayName + " Driver")}
|
|
}
|
|
],
|
|
ui: {
|
|
slots: [
|
|
{
|
|
type: "dashboardWidget",
|
|
id: "health-widget",
|
|
displayName: ${quote(`${displayName} Health`)},
|
|
exportName: "DashboardWidget"
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
export default manifest;
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "src", "worker.ts"),
|
|
`import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
|
import type {
|
|
PluginEnvironmentValidateConfigParams,
|
|
PluginEnvironmentProbeParams,
|
|
PluginEnvironmentAcquireLeaseParams,
|
|
PluginEnvironmentResumeLeaseParams,
|
|
PluginEnvironmentReleaseLeaseParams,
|
|
PluginEnvironmentDestroyLeaseParams,
|
|
PluginEnvironmentRealizeWorkspaceParams,
|
|
PluginEnvironmentExecuteParams,
|
|
} from "@paperclipai/plugin-sdk";
|
|
|
|
const plugin = definePlugin({
|
|
async setup(ctx) {
|
|
ctx.data.register("health", async () => {
|
|
return { status: "ok", checkedAt: new Date().toISOString() };
|
|
});
|
|
},
|
|
|
|
async onHealth() {
|
|
return { status: "ok", message: "Environment plugin worker is running" };
|
|
},
|
|
|
|
async onEnvironmentValidateConfig(params: PluginEnvironmentValidateConfigParams) {
|
|
if (!params.config || typeof params.config !== "object") {
|
|
return { ok: false, errors: ["Config must be a non-null object"] };
|
|
}
|
|
return { ok: true, normalizedConfig: params.config };
|
|
},
|
|
|
|
async onEnvironmentProbe(_params: PluginEnvironmentProbeParams) {
|
|
return { ok: true, summary: "Environment is reachable" };
|
|
},
|
|
|
|
async onEnvironmentAcquireLease(params: PluginEnvironmentAcquireLeaseParams) {
|
|
const providerLeaseId = \`lease-\${params.runId}-\${Date.now()}\`;
|
|
return {
|
|
providerLeaseId,
|
|
metadata: { acquiredAt: new Date().toISOString() },
|
|
};
|
|
},
|
|
|
|
async onEnvironmentResumeLease(params: PluginEnvironmentResumeLeaseParams) {
|
|
return {
|
|
providerLeaseId: params.providerLeaseId,
|
|
metadata: { ...params.leaseMetadata, resumed: true },
|
|
};
|
|
},
|
|
|
|
async onEnvironmentReleaseLease(_params: PluginEnvironmentReleaseLeaseParams) {
|
|
// Release provider-side resources here
|
|
},
|
|
|
|
async onEnvironmentDestroyLease(_params: PluginEnvironmentDestroyLeaseParams) {
|
|
// Destroy provider-side resources here
|
|
},
|
|
|
|
async onEnvironmentRealizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams) {
|
|
const cwd = params.workspace.remotePath ?? params.workspace.localPath ?? "/tmp/workspace";
|
|
return { cwd, metadata: { realized: true } };
|
|
},
|
|
|
|
async onEnvironmentExecute(params: PluginEnvironmentExecuteParams) {
|
|
// Replace this with real command execution against your provider
|
|
return {
|
|
exitCode: 0,
|
|
timedOut: false,
|
|
stdout: \`Executed: \${params.command}\`,
|
|
stderr: "",
|
|
};
|
|
},
|
|
});
|
|
|
|
export default plugin;
|
|
runWorker(plugin, import.meta.url);
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "src", "ui", "index.tsx"),
|
|
`import { usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
|
|
|
type HealthData = {
|
|
status: "ok" | "degraded" | "error";
|
|
checkedAt: string;
|
|
};
|
|
|
|
export function DashboardWidget(_props: PluginWidgetProps) {
|
|
const { data, loading, error } = usePluginData<HealthData>("health");
|
|
|
|
if (loading) return <div>Loading environment health...</div>;
|
|
if (error) return <div>Plugin error: {error.message}</div>;
|
|
|
|
return (
|
|
<div style={{ display: "grid", gap: "0.5rem" }}>
|
|
<strong>${displayName}</strong>
|
|
<div>Health: {data?.status ?? "unknown"}</div>
|
|
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "tests", "plugin.spec.ts"),
|
|
`import { describe, expect, it } from "vitest";
|
|
import {
|
|
createEnvironmentTestHarness,
|
|
createFakeEnvironmentDriver,
|
|
assertEnvironmentEventOrder,
|
|
assertLeaseLifecycle,
|
|
} from "@paperclipai/plugin-sdk/testing";
|
|
import manifest from "../src/manifest.js";
|
|
import plugin from "../src/worker.js";
|
|
|
|
const ENV_ID = "env-test-1";
|
|
const BASE_PARAMS = {
|
|
driverKey: manifest.environmentDrivers![0].driverKey,
|
|
companyId: "co-1",
|
|
environmentId: ENV_ID,
|
|
config: {},
|
|
};
|
|
|
|
describe("environment plugin scaffold", () => {
|
|
it("declares capabilities for its manifest features", () => {
|
|
expect(manifest.capabilities).toContain("environment.drivers.register");
|
|
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
|
|
});
|
|
|
|
it("validates config", async () => {
|
|
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
|
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
|
driverKey: BASE_PARAMS.driverKey,
|
|
config: { host: "test" },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("probes the environment", async () => {
|
|
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
|
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const result = await plugin.definition.onEnvironmentProbe!(BASE_PARAMS);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("runs a full lease lifecycle through the harness", async () => {
|
|
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
|
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
|
|
expect(lease.providerLeaseId).toBeTruthy();
|
|
|
|
await harness.realizeWorkspace({
|
|
...BASE_PARAMS,
|
|
lease,
|
|
workspace: { localPath: "/tmp/test" },
|
|
});
|
|
|
|
await harness.releaseLease({
|
|
...BASE_PARAMS,
|
|
providerLeaseId: lease.providerLeaseId,
|
|
});
|
|
|
|
assertEnvironmentEventOrder(harness.environmentEvents, [
|
|
"acquireLease",
|
|
"realizeWorkspace",
|
|
"releaseLease",
|
|
]);
|
|
assertLeaseLifecycle(harness.environmentEvents, ENV_ID);
|
|
});
|
|
});
|
|
`,
|
|
);
|
|
} else {
|
|
writeFile(
|
|
path.join(outputDir, "src", "manifest.ts"),
|
|
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
|
|
|
const manifest: PaperclipPluginManifestV1 = {
|
|
id: ${quote(manifestId)},
|
|
apiVersion: 1,
|
|
version: "0.1.0",
|
|
displayName: ${quote(displayName)},
|
|
description: ${quote(description)},
|
|
author: ${quote(author)},
|
|
categories: [${quote(category)}],
|
|
capabilities: [
|
|
"events.subscribe",
|
|
"plugin.state.read",
|
|
"plugin.state.write",
|
|
"ui.dashboardWidget.register"
|
|
],
|
|
entrypoints: {
|
|
worker: "./dist/worker.js",
|
|
ui: "./dist/ui"
|
|
},
|
|
ui: {
|
|
slots: [
|
|
{
|
|
type: "dashboardWidget",
|
|
id: "health-widget",
|
|
displayName: ${quote(`${displayName} Health`)},
|
|
exportName: "DashboardWidget"
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
export default manifest;
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "src", "worker.ts"),
|
|
`import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
|
|
|
const plugin = definePlugin({
|
|
async setup(ctx) {
|
|
ctx.events.on("issue.created", async (event) => {
|
|
const issueId = event.entityId ?? "unknown";
|
|
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
|
|
ctx.logger.info("Observed issue.created", { issueId });
|
|
});
|
|
|
|
ctx.data.register("health", async () => {
|
|
return { status: "ok", checkedAt: new Date().toISOString() };
|
|
});
|
|
|
|
ctx.actions.register("ping", async () => {
|
|
ctx.logger.info("Ping action invoked");
|
|
return { pong: true, at: new Date().toISOString() };
|
|
});
|
|
},
|
|
|
|
async onHealth() {
|
|
return { status: "ok", message: "Plugin worker is running" };
|
|
}
|
|
});
|
|
|
|
export default plugin;
|
|
runWorker(plugin, import.meta.url);
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "src", "ui", "index.tsx"),
|
|
`import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
|
|
|
type HealthData = {
|
|
status: "ok" | "degraded" | "error";
|
|
checkedAt: string;
|
|
};
|
|
|
|
export function DashboardWidget(_props: PluginWidgetProps) {
|
|
const { data, loading, error } = usePluginData<HealthData>("health");
|
|
const ping = usePluginAction("ping");
|
|
|
|
if (loading) return <div>Loading plugin health...</div>;
|
|
if (error) return <div>Plugin error: {error.message}</div>;
|
|
|
|
return (
|
|
<div style={{ display: "grid", gap: "0.5rem" }}>
|
|
<strong>${displayName}</strong>
|
|
<div>Health: {data?.status ?? "unknown"}</div>
|
|
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
|
<button onClick={() => void ping()}>Ping Worker</button>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "tests", "plugin.spec.ts"),
|
|
`import { describe, expect, it } from "vitest";
|
|
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
|
import manifest from "../src/manifest.js";
|
|
import plugin from "../src/worker.js";
|
|
|
|
describe("plugin scaffold", () => {
|
|
it("declares capabilities for its manifest features", () => {
|
|
expect(manifest.capabilities).toContain("events.subscribe");
|
|
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
|
|
});
|
|
|
|
it("registers data + actions and handles events", async () => {
|
|
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
|
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
|
|
|
|
const data = await harness.getData<{ status: string }>("health");
|
|
expect(data.status).toBe("ok");
|
|
|
|
const action = await harness.performAction<{ pong: boolean }>("ping");
|
|
expect(action.pong).toBe(true);
|
|
});
|
|
});
|
|
`,
|
|
);
|
|
}
|
|
|
|
writeFile(
|
|
path.join(outputDir, "README.md"),
|
|
`# ${displayName}
|
|
|
|
${description}
|
|
|
|
## Development
|
|
|
|
\`\`\`bash
|
|
pnpm install
|
|
pnpm dev # watch builds
|
|
pnpm dev:ui # local dev server with hot-reload events
|
|
pnpm test
|
|
\`\`\`
|
|
|
|
\`pnpm dev\` rebuilds the worker, manifest, and UI bundles into \`dist/\`.
|
|
When this package is installed from a local path, Paperclip watches that rebuilt
|
|
output and reloads the plugin worker. Local installs run trusted code from this
|
|
folder on your machine.
|
|
|
|
${sdkDependency.startsWith("file:")
|
|
? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n`
|
|
: ""}
|
|
|
|
## Install Into Paperclip
|
|
|
|
\`\`\`bash
|
|
paperclipai plugin install ${shellQuote(toPosixPath(outputDir))}
|
|
\`\`\`
|
|
|
|
## Build Options
|
|
|
|
- \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`.
|
|
- \`pnpm build:rollup\` uses rollup presets from the same SDK.
|
|
`,
|
|
);
|
|
|
|
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n");
|
|
|
|
return outputDir;
|
|
}
|