mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Polish local plugin install CLI loop
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
449fdca730
commit
c904a4683d
3 files changed
+142
-28
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<PluginInstallOptions, "local" | "version"> = {},
|
||||
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<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user