diff --git a/cli/src/__tests__/plugin-init.test.ts b/cli/src/__tests__/plugin-init.test.ts index dbda1e01268..de6e8fabdd6 100644 --- a/cli/src/__tests__/plugin-init.test.ts +++ b/cli/src/__tests__/plugin-init.test.ts @@ -1,6 +1,8 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir), @@ -11,11 +13,27 @@ vi.mock("@paperclipai/create-paperclip-plugin", () => ({ })); import { + buildPluginInstallRequest, buildPluginInitNextCommands, buildPluginInitScaffoldOptions, registerPluginCommands, } from "../commands/client/plugin.js"; +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-plugin-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + describe("plugin init", () => { beforeEach(() => { mocks.scaffoldPluginProject.mockClear(); @@ -100,3 +118,40 @@ describe("plugin init", () => { }); }); }); + +describe("plugin install", () => { + it("resolves an existing relative local path to an absolute local install request", () => { + const cwd = makeTempDir(); + const pluginDir = path.join(cwd, "demo-plugin"); + fs.mkdirSync(pluginDir); + + expect(buildPluginInstallRequest("demo-plugin", {}, { cwd })).toEqual({ + packageName: pluginDir, + version: undefined, + isLocalPath: true, + }); + }); + + it("keeps an absolute local path absolute and marks it as local", () => { + const pluginDir = path.join(makeTempDir(), "demo-plugin"); + fs.mkdirSync(pluginDir); + + expect(buildPluginInstallRequest(pluginDir, {}, { cwd: "/" })).toEqual({ + packageName: pluginDir, + version: undefined, + isLocalPath: true, + }); + }); + + it("preserves npm package installs when no local path exists", () => { + expect( + buildPluginInstallRequest("@acme/plugin-linear", { version: "1.2.3" }, { + cwd: makeTempDir(), + }), + ).toEqual({ + packageName: "@acme/plugin-linear", + version: "1.2.3", + isLocalPath: false, + }); + }); +}); diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index e756530e82a..60a8e3ba145 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { existsSync } from "node:fs"; import { Command, Option } from "commander"; import { scaffoldPluginProject, @@ -43,6 +44,12 @@ interface PluginInstallOptions extends BaseClientOptions { version?: string; } +interface PluginInstallRequest { + packageName: string; + version?: string; + isLocalPath: boolean; +} + interface PluginUninstallOptions extends BaseClientOptions { force?: boolean; } @@ -66,20 +73,72 @@ interface PluginInitResult { // Helpers // --------------------------------------------------------------------------- +function expandHomePath(packageArg: string): string { + if (!packageArg.startsWith("~")) return packageArg; + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, "")); +} + +function hasLocalPathSyntax(packageArg: string): boolean { + return ( + path.isAbsolute(packageArg) || + packageArg.startsWith("./") || + packageArg.startsWith("../") || + packageArg.startsWith("~") || + packageArg.startsWith(".\\") || + packageArg.startsWith("..\\") + ); +} + +function isExistingRelativePath( + packageArg: string, + cwd: string, + pathExists: (targetPath: string) => boolean, +): boolean { + if (packageArg.trim() === "") return false; + if (hasLocalPathSyntax(packageArg)) return false; + return pathExists(path.resolve(cwd, packageArg)); +} + /** * Resolve a local path argument to an absolute path so the server can find the * plugin on disk regardless of where the user ran the CLI. */ -function resolvePackageArg(packageArg: string, isLocal: boolean): string { +function resolvePackageArg(packageArg: string, isLocal: boolean, cwd = process.cwd()): string { if (!isLocal) return packageArg; - // Already absolute if (path.isAbsolute(packageArg)) return packageArg; - // Expand leading ~ to home directory - if (packageArg.startsWith("~")) { - const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; - return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, "")); + if (packageArg.startsWith("~")) return expandHomePath(packageArg); + return path.resolve(cwd, packageArg); +} + +export function buildPluginInstallRequest( + packageArg: string, + opts: Pick = {}, + deps: { cwd?: string; existsSync?: (targetPath: string) => boolean } = {}, +): PluginInstallRequest { + const cwd = deps.cwd ?? process.cwd(); + const pathExists = deps.existsSync ?? existsSync; + const isLocal = + opts.local || + hasLocalPathSyntax(packageArg) || + (opts.version ? false : isExistingRelativePath(packageArg, cwd, pathExists)); + + if (isLocal && opts.version) { + throw new Error("--version is only supported for npm package installs, not local plugin paths."); } - return path.resolve(process.cwd(), packageArg); + + return { + packageName: resolvePackageArg(packageArg, Boolean(isLocal), cwd), + version: opts.version, + isLocalPath: Boolean(isLocal), + }; +} + +export function renderLocalPluginInstallHint(packagePath: string): string { + return [ + pc.dim("Local plugin installs run trusted local code from your machine."), + pc.dim(`Keep ${pc.cyan("pnpm dev")} running in ${packagePath}; Paperclip watches rebuilt dist output and reloads the plugin worker.`), + ].join("\n"); } function formatPlugin(p: PluginRecord): string { @@ -260,31 +319,19 @@ export function registerPluginCommands(program: Command): void { try { const ctx = resolveCommandContext(opts); - // Auto-detect local paths: starts with . or / or ~ or is an absolute path - const isLocal = - opts.local || - packageArg.startsWith("./") || - packageArg.startsWith("../") || - packageArg.startsWith("/") || - packageArg.startsWith("~"); - - const resolvedPackage = resolvePackageArg(packageArg, isLocal); + const installRequest = buildPluginInstallRequest(packageArg, opts); if (!ctx.json) { console.log( pc.dim( - isLocal - ? `Installing plugin from local path: ${resolvedPackage}` - : `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`, + installRequest.isLocalPath + ? `Installing plugin from local path: ${installRequest.packageName}` + : `Installing plugin: ${installRequest.packageName}${opts.version ? `@${opts.version}` : ""}`, ), ); } - const installedPlugin = await ctx.api.post("/api/plugins/install", { - packageName: resolvedPackage, - version: opts.version, - isLocalPath: isLocal, - }); + const installedPlugin = await ctx.api.post("/api/plugins/install", installRequest); if (ctx.json) { printOutput(installedPlugin, { json: true }); @@ -305,6 +352,10 @@ export function registerPluginCommands(program: Command): void { if (installedPlugin.lastError) { console.log(pc.red(` Warning: ${installedPlugin.lastError}`)); } + + if (installRequest.isLocalPath) { + console.log(renderLocalPluginInstallHint(installRequest.packageName)); + } } catch (err) { handleCommandError(err); } diff --git a/packages/plugins/create-paperclip-plugin/src/index.ts b/packages/plugins/create-paperclip-plugin/src/index.ts index 5fc17b053f8..ae27e4db867 100644 --- a/packages/plugins/create-paperclip-plugin/src/index.ts +++ b/packages/plugins/create-paperclip-plugin/src/index.ts @@ -62,6 +62,11 @@ function toPosixPath(value: string): string { return value.split(path.sep).join("/"); } +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))}`; } @@ -656,6 +661,11 @@ 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` : ""} @@ -663,9 +673,7 @@ ${sdkDependency.startsWith("file:") ## Install Into Paperclip \`\`\`bash -curl -X POST http://127.0.0.1:3100/api/plugins/install \\ - -H "Content-Type: application/json" \\ - -d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}' +paperclipai plugin install ${shellQuote(toPosixPath(outputDir))} \`\`\` ## Build Options