mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
fix(cloud): resolve bundled plugins from current image identity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b826940a69
commit
10d8a9ce89
10 files changed
+515
-27
No files matched your search
@@ -34,6 +34,32 @@ Current limitations to keep in mind:
|
||||
|
||||
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
||||
|
||||
### Managed image packages
|
||||
|
||||
Managed Cloud provisions only plugins selected from the current release image
|
||||
catalog. The database registration's plugin key, package name, version and
|
||||
approved manifest identify that package. Its legacy `packagePath` remains for
|
||||
local/self-hosted compatibility; Cloud never uses that path to locate code.
|
||||
Workers, plugin migrations and UI files resolve through the same current-image
|
||||
package resolver. Cloud does not install npm dependencies, build packages, use
|
||||
the development UI proxy, or fall back to a server home directory.
|
||||
|
||||
Equivalent packages at different image roots reuse the existing registration,
|
||||
configuration, approval status and company scopes without rewriting its stored
|
||||
path. Concurrent first registration of the same package retains one plugin ID.
|
||||
Conflicting package identities fail explicitly. Normal maintenance boot retains
|
||||
the existing bundled manifest/version reconciliation and approval gates.
|
||||
|
||||
`createBundledPluginPackageResolver(...).inspect(registration, verifiedManifest)`
|
||||
is a read-only qualification hook. Resolve the current image path, load its
|
||||
manifest with the validated plugin loader, and inspect the registration before
|
||||
normal boot reconciliation. Missing manifest evidence and incompatible packages
|
||||
produce explicit blockers. Distribution packages retain their catalog digest
|
||||
verification; built-in packages trust the immutable release image and must bump
|
||||
their version when their code changes. Qualification supports identical package
|
||||
sets only. This package-resolution boundary does not coordinate workers,
|
||||
background jobs, or deployment overlap between servers.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This spec covers:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { rmSync, writeFileSync, symlinkSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { bundledImageFixture } from "./helpers/bundled-image-fixture.js";
|
||||
import { createBundledPluginPackageResolver } from "../services/bundled-plugin-package.js";
|
||||
import { BUNDLED_PLUGIN_CATALOG, resolveBundledPluginInstalls } from "../services/bundled-plugins.js";
|
||||
import { readDistributionPluginCatalog } from "../services/distribution-plugin-catalog.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
function fixture() { const result = bundledImageFixture(); roots.push(result.root); return result; }
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
describe("bundled image package identity", () => {
|
||||
it("resolves independent image roots from the same registration without any previous path", () => {
|
||||
const first = fixture();
|
||||
const second = fixture();
|
||||
const registration = { ...first.registration, packagePath: "/lost/old/home/plugins/fixture" };
|
||||
expect(first.resolver.resolve(registration)).toBe(first.packageRoot);
|
||||
expect(second.resolver.resolve(registration)).toBe(second.packageRoot);
|
||||
expect(first.resolver.inspect(registration, first.manifest)).toEqual(second.resolver.inspect(registration, second.manifest));
|
||||
expect(registration.packagePath).toBe("/lost/old/home/plugins/fixture");
|
||||
});
|
||||
|
||||
it("requires verified manifest evidence and blocks same-version manifest drift", () => {
|
||||
const { resolver, registration, manifest } = fixture();
|
||||
expect(resolver.inspect(registration)).toMatchObject({ compatible: false, code: "missing_manifest_evidence" });
|
||||
expect(resolver.inspect(registration, { ...manifest, description: "changed" })).toMatchObject({ compatible: false, code: "incompatible_bundled_package", reason: expect.stringContaining("Approved plugin manifest") });
|
||||
expect(resolver.inspect(registration, manifest)).toMatchObject({ compatible: true, identity: { kind: "bundled_image", catalogKey: "fixture", pluginKey: manifest.id, version: "1.0.0" } });
|
||||
});
|
||||
|
||||
it("rejects mismatched package names, versions, identity and escaping entrypoints", () => {
|
||||
const { resolver, registration } = fixture();
|
||||
for (const update of [
|
||||
{ packageName: "@other/plugin" }, { version: "2.0.0" }, { pluginKey: "other.plugin" },
|
||||
{ manifestJson: { ...registration.manifestJson, version: "2.0.0" } },
|
||||
{ manifestJson: { ...registration.manifestJson, entrypoints: { worker: "../worker.js" } } },
|
||||
]) expect(() => resolver.resolve({ ...registration, ...update })).toThrow();
|
||||
});
|
||||
|
||||
it("fails explicitly for missing or deselected bundles even when a stale npm package exists", () => {
|
||||
const { root, packageRoot, resolver, registration } = fixture();
|
||||
const fallback = path.join(root, "home", "node_modules", registration.packageName);
|
||||
mkdirSync(fallback, { recursive: true });
|
||||
writeFileSync(path.join(fallback, "worker.js"), "throw new Error('unsafe fallback');");
|
||||
rmSync(packageRoot, { recursive: true });
|
||||
expect(() => resolver.resolve(registration)).toThrow(/ENOENT/);
|
||||
expect(() => createBundledPluginPackageResolver([]).resolve(registration)).toThrow(/absent or not selected/);
|
||||
});
|
||||
|
||||
it("rejects tampering before manifest import and symlink escapes before worker resolution", () => {
|
||||
const { root, packageRoot, registration, resolver } = fixture();
|
||||
writeFileSync(path.join(packageRoot, "dist", "manifest.js"), "throw new Error('must never import');");
|
||||
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/digest mismatch/);
|
||||
rmSync(path.join(packageRoot, "dist", "worker.js"));
|
||||
symlinkSync(path.join(root, "distribution", "catalog.json"), path.join(packageRoot, "dist", "worker.js"));
|
||||
expect(() => resolver.resolve(registration)).toThrow(/escapes/);
|
||||
});
|
||||
|
||||
it("applies the same identity and containment checks to managed built-in packages", () => {
|
||||
const { root, packageRoot, manifest, registration } = fixture();
|
||||
const installs = resolveBundledPluginInstalls(["kubernetes"], { catalogRoot: root, enforceCatalogRoot: true, distributionPlugins: [], env: {} });
|
||||
installs[0] = { ...installs[0]!, localPath: packageRoot, pluginKey: manifest.id };
|
||||
expect(createBundledPluginPackageResolver(installs).resolve(registration)).toBe(packageRoot);
|
||||
expect(() => createBundledPluginPackageResolver(installs).resolve({ ...registration, version: "2.0.0" })).toThrow(/identity\/version/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { rmSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { applyPendingMigrations, closeRegisteredClients, companies, createDb, plugins } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PluginRecord } from "@paperclipai/shared";
|
||||
import { createPluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { pluginLoader, type PluginRuntimeServices } from "../services/plugin-loader.js";
|
||||
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||
import { pluginUiStaticRoutes } from "../routes/plugin-ui-static.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { derivePluginDatabaseNamespace } from "../services/plugin-database.js";
|
||||
import { ensureBundledPlugins } from "../services/bundled-plugins.js";
|
||||
import { bundledImageFixture } from "./helpers/bundled-image-fixture.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const externalDatabaseUrl = process.env.PAPERCLIP_BUNDLED_PLUGIN_TEST_DATABASE_URL?.trim();
|
||||
const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
describePostgres("bundled image registration in PostgreSQL", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let peer: ReturnType<typeof createDb>;
|
||||
const roots: string[] = [];
|
||||
const fixture = () => { const result = bundledImageFixture(); roots.push(result.root); return result; };
|
||||
beforeAll(async () => {
|
||||
if (externalDatabaseUrl) {
|
||||
await applyPendingMigrations(externalDatabaseUrl);
|
||||
database = { connectionString: externalDatabaseUrl, cleanup: () => closeRegisteredClients(externalDatabaseUrl) };
|
||||
} else {
|
||||
database = await startEmbeddedPostgresTestDatabase("bundled-image-registration-");
|
||||
}
|
||||
db = createDb(database.connectionString);
|
||||
peer = createDb(database.connectionString);
|
||||
}, 90_000);
|
||||
afterEach(async () => {
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
function runtime() {
|
||||
const output: string[] = [];
|
||||
const services = {
|
||||
workerManager: {
|
||||
startWorker: vi.fn(async (_id: string, options: { entrypointPath: string }) => { output.push(execFileSync(process.execPath, [options.entrypointPath, "--source"], { encoding: "utf8" })); }),
|
||||
call: vi.fn(async () => ({})),
|
||||
},
|
||||
lifecycleManager: { markError: vi.fn() },
|
||||
jobStore: { syncJobDeclarations: vi.fn() }, jobScheduler: { registerPlugin: vi.fn() },
|
||||
toolDispatcher: {}, eventBus: { forPlugin: vi.fn(), subscriptionCount: vi.fn(() => 0) },
|
||||
buildHostHandlers: vi.fn(() => ({})), instanceInfo: { instanceId: "fixture", hostVersion: "1.0.0" },
|
||||
};
|
||||
return { services, output };
|
||||
}
|
||||
function loaderFor(image: ReturnType<typeof fixture>, databaseClient = db, services?: PluginRuntimeServices) {
|
||||
return pluginLoader(databaseClient, {
|
||||
localPluginDir: path.join(image.root, "lost-home"), prebuiltOnly: true,
|
||||
resolvePackageRoot: image.resolver.resolve, assertPackageActivation: image.resolver.assertActivation,
|
||||
enableLocalFilesystem: false, enableNpmDiscovery: false,
|
||||
}, services);
|
||||
}
|
||||
|
||||
it("starts current-image workers after losing the old home and retains each company's config", async () => {
|
||||
const old = fixture();
|
||||
const first = fixture();
|
||||
const second = fixture();
|
||||
await loaderFor(old).installPlugin({ localPath: old.packageRoot });
|
||||
const registry = pluginRegistryService(db);
|
||||
let row = (await registry.getByKey(old.manifest.id))!;
|
||||
await registry.updateStatus(row.id, { status: "ready" });
|
||||
const [companyA, companyB, unconfigured] = await db.insert(companies).values([
|
||||
{ name: "Company A", issuePrefix: "CFA" }, { name: "Company B", issuePrefix: "CFB" }, { name: "Unconfigured", issuePrefix: "CFC" },
|
||||
]).returning();
|
||||
await registry.upsertConfig(row.id, companyA!.id, { configJson: { token: "only-A" } });
|
||||
await registry.upsertConfig(row.id, companyB!.id, { configJson: { token: "only-B" } });
|
||||
row = (await registry.getById(row.id))!;
|
||||
rmSync(old.root, { recursive: true });
|
||||
const original = { ...row };
|
||||
for (const image of [first, second]) {
|
||||
const current = runtime();
|
||||
const loader = loaderFor(image, db, current.services as unknown as PluginRuntimeServices);
|
||||
const result = await loader.loadSingle(row.id);
|
||||
expect(result.success).toBe(true);
|
||||
expect(current.output).toEqual([expect.stringContaining(image.packageRoot)]);
|
||||
expect(current.services.workerManager.startWorker).toHaveBeenCalledWith(row.id, expect.objectContaining({
|
||||
entrypointPath: path.join(image.packageRoot, "dist", "worker.js"), config: {}, proactiveCompanyScopes: [companyA!.id, companyB!.id].sort(),
|
||||
}));
|
||||
expect(current.services.workerManager.call.mock.calls).toEqual(expect.arrayContaining([
|
||||
[row.id, "configChanged", { companyId: companyA!.id, config: { token: "only-A" } }],
|
||||
[row.id, "configChanged", { companyId: companyB!.id, config: { token: "only-B" } }],
|
||||
]));
|
||||
expect(current.services.workerManager.call).toHaveBeenCalledTimes(2);
|
||||
expect(await registry.getConfig(row.id, unconfigured!.id)).toBeNull();
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", userId: "fixture-user", source: "session", isInstanceAdmin: false, companyIds: [companyA!.id] };
|
||||
next();
|
||||
});
|
||||
app.use(pluginUiStaticRoutes(db, { localPluginDir: path.join(image.root, "lost-home"), resolvePackageRoot: image.resolver.resolve }));
|
||||
app.use(errorHandler);
|
||||
const served = await request(app).get(`/_plugins/${row.id}/ui/index.js`).query({ companyId: companyA!.id });
|
||||
expect(served.status).toBe(200);
|
||||
expect(served.text).toContain("current-image");
|
||||
expect((await request(app).get(`/_plugins/${row.id}/ui/index.js`).query({ companyId: companyB!.id })).status).toBe(403);
|
||||
|
||||
}
|
||||
expect(await registry.getById(row.id)).toEqual(original);
|
||||
});
|
||||
|
||||
it("completes the real worker initialize and health protocol from a relocated image", async () => {
|
||||
const old = fixture(); const image = fixture();
|
||||
await loaderFor(old).installPlugin({ localPath: old.packageRoot });
|
||||
const registry = pluginRegistryService(db);
|
||||
const row = (await registry.getByKey(old.manifest.id))!;
|
||||
await registry.updateStatus(row.id, { status: "ready" });
|
||||
rmSync(old.root, { recursive: true });
|
||||
const manager = createPluginWorkerManager();
|
||||
try {
|
||||
const services = { ...runtime().services, workerManager: manager } as unknown as PluginRuntimeServices;
|
||||
const result = await loaderFor(image, db, services).loadSingle(row.id);
|
||||
expect(result.success).toBe(true);
|
||||
expect(await manager.call(row.id, "health", {})).toMatchObject({ status: "ok", details: { source: expect.stringContaining(image.packageRoot), configs: {} } });
|
||||
} finally { await manager.stopAll(); }
|
||||
});
|
||||
|
||||
it("registers the same package concurrently from two roots without replacing the winner", async () => {
|
||||
const first = fixture(); const second = fixture();
|
||||
await Promise.all([
|
||||
loaderFor(first).installPlugin({ localPath: first.packageRoot }),
|
||||
loaderFor(second, peer).installPlugin({ localPath: second.packageRoot }),
|
||||
]);
|
||||
const rows = await pluginRegistryService(db).listInstalled();
|
||||
expect(rows).toHaveLength(1);
|
||||
const original = rows[0]!;
|
||||
expect([first.packageRoot, second.packageRoot]).toContain(original.packagePath);
|
||||
await loaderFor(second, peer).installPlugin({ localPath: second.packageRoot });
|
||||
expect(await pluginRegistryService(db).getById(original.id)).toEqual(original);
|
||||
await expect(pluginRegistryService(peer).install({ packageName: original.packageName }, { ...first.manifest, description: "different package metadata" }, { idempotent: true })).rejects.toThrow(/already installed/);
|
||||
});
|
||||
|
||||
it("preserves disabled registrations and avoids path writes during managed boot", async () => {
|
||||
const old = fixture(); const image = fixture();
|
||||
await loaderFor(old).installPlugin({ localPath: old.packageRoot });
|
||||
const registry = pluginRegistryService(db);
|
||||
const row = (await registry.getByKey(old.manifest.id))!;
|
||||
await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, row.id));
|
||||
const original = await registry.getById(row.id);
|
||||
await ensureBundledPlugins(image.installs, { registry, loader: loaderFor(image), lifecycle: { load: vi.fn() }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, { reinstallUninstalled: true, preservePackagePath: true });
|
||||
expect(await registry.getById(row.id)).toEqual(original);
|
||||
});
|
||||
|
||||
it("fails before worker startup for missing image bytes without using a home fallback or building", async () => {
|
||||
const image = fixture();
|
||||
await loaderFor(image).installPlugin({ localPath: image.packageRoot });
|
||||
const registry = pluginRegistryService(db);
|
||||
const row = (await registry.getByKey(image.manifest.id))!;
|
||||
await registry.updateStatus(row.id, { status: "ready" });
|
||||
const fallback = path.join(image.root, "lost-home", "node_modules", row.packageName);
|
||||
mkdirSync(fallback, { recursive: true });
|
||||
writeFileSync(path.join(fallback, "package.json"), JSON.stringify({ name: row.packageName }));
|
||||
writeFileSync(path.join(fallback, "worker.js"), "throw new Error('must not start');");
|
||||
rmSync(path.join(image.packageRoot, "dist", "worker.js"));
|
||||
const current = runtime();
|
||||
const loader = loaderFor(image, db, current.services as unknown as PluginRuntimeServices);
|
||||
const result = await loader.loadSingle(row.id);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("ENOENT");
|
||||
expect(current.services.workerManager.startWorker).not.toHaveBeenCalled();
|
||||
await expect(loader.installPlugin({ packageName: row.packageName })).rejects.toThrow(/current image catalog/);
|
||||
await loader.cleanupInstallArtifacts(row as PluginRecord);
|
||||
expect(image.resolver.inspect(row, image.manifest).compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("applies plugin migrations from the current image after the legacy package disappears", async () => {
|
||||
const image = bundledImageFixture({
|
||||
capabilities: ["database.namespace.migrate", "database.namespace.read", "database.namespace.write"],
|
||||
database: { migrationsDir: "migrations" },
|
||||
});
|
||||
roots.push(image.root);
|
||||
const [row] = await db.insert(plugins).values({
|
||||
...image.registration, packagePath: "/lost/legacy/home/plugin", apiVersion: 1, categories: image.manifest.categories, status: "ready",
|
||||
}).returning();
|
||||
const current = runtime();
|
||||
const result = await loaderFor(image, db, current.services as unknown as PluginRuntimeServices).loadSingle(row!.id);
|
||||
expect(result.success).toBe(true);
|
||||
expect(current.services.workerManager.startWorker).toHaveBeenCalledWith(row!.id, expect.objectContaining({ databaseNamespace: derivePluginDatabaseNamespace(image.manifest.id) }));
|
||||
await db.execute((await import("drizzle-orm")).sql.raw(`DROP SCHEMA "${derivePluginDatabaseNamespace(image.manifest.id)}" CASCADE`));
|
||||
});
|
||||
|
||||
it("keeps self-hosted local-path loading available without a managed resolver", async () => {
|
||||
const image = fixture();
|
||||
const loader = pluginLoader(db, { localPluginDir: path.join(image.root, "selfhost"), enableNpmDiscovery: false, enableLocalFilesystem: false });
|
||||
await loader.installPlugin({ localPath: image.packageRoot });
|
||||
const row = (await pluginRegistryService(db).getByKey(image.manifest.id))!;
|
||||
expect(row.packagePath).toBe(image.packageRoot);
|
||||
await expect(loader.installPlugin({ localPath: image.packageRoot })).rejects.toThrow(/already installed/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { mkdtempSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { BUNDLED_PLUGIN_CATALOG, resolveBundledPluginInstalls } from "../../services/bundled-plugins.js";
|
||||
import { distributionBundleDigest, readDistributionPluginCatalog } from "../../services/distribution-plugin-catalog.js";
|
||||
import { derivePluginDatabaseNamespace } from "../../services/plugin-database.js";
|
||||
import { createBundledPluginPackageResolver } from "../../services/bundled-plugin-package.js";
|
||||
|
||||
export function bundledImageFixture(manifestOverrides: Partial<PaperclipPluginManifestV1> = {}) {
|
||||
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "bundled-image-")));
|
||||
const packageRoot = path.join(root, "distribution", "fixture");
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclip.image-fixture", apiVersion: 1, version: "1.0.0", displayName: "Image Fixture",
|
||||
description: "Image package relocation fixture", author: "Paperclip", categories: ["automation"], capabilities: ["issues.read"],
|
||||
entrypoints: { worker: "dist/worker.js", ui: "dist/ui" }, ...manifestOverrides,
|
||||
};
|
||||
mkdirSync(path.join(packageRoot, "dist", "ui"), { recursive: true });
|
||||
writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({
|
||||
name: "@paperclipai/plugin-image-fixture", version: manifest.version, type: "module",
|
||||
paperclipPlugin: { manifest: "dist/manifest.js", ...manifest.entrypoints },
|
||||
}));
|
||||
writeFileSync(path.join(packageRoot, "dist", "manifest.js"), `export default ${JSON.stringify(manifest)};`);
|
||||
writeFileSync(path.join(packageRoot, "dist", "worker.js"), `import readline from "node:readline";
|
||||
if (process.argv.includes("--source")) process.stdout.write(import.meta.url);
|
||||
else {
|
||||
const configs = {};
|
||||
readline.createInterface({ input: process.stdin }).on("line", line => {
|
||||
const request = JSON.parse(line);
|
||||
let result = {};
|
||||
if (request.method === "initialize") result = { ok: true, supportedMethods: ["configChanged", "health"] };
|
||||
if (request.method === "configChanged") configs[request.params.companyId] = request.params.config;
|
||||
if (request.method === "health") result = { status: "ok", details: { source: import.meta.url, configs } };
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n");
|
||||
if (request.method === "shutdown") setImmediate(() => process.exit(0));
|
||||
});
|
||||
}`);
|
||||
writeFileSync(path.join(packageRoot, "dist", "ui", "index.js"), "export const source = 'current-image';");
|
||||
if (manifest.database) {
|
||||
mkdirSync(path.join(packageRoot, manifest.database.migrationsDir), { recursive: true });
|
||||
writeFileSync(path.join(packageRoot, manifest.database.migrationsDir, "001_fixture.sql"), `CREATE TABLE ${derivePluginDatabaseNamespace(manifest.id)}.image_rows (id uuid PRIMARY KEY);`);
|
||||
}
|
||||
const entry = { key: "fixture", pluginKey: manifest.id, version: manifest.version, directory: "fixture", digest: distributionBundleDigest(packageRoot) };
|
||||
writeFileSync(path.join(root, "distribution", "catalog.json"), JSON.stringify({ schemaVersion: 1, plugins: [entry] }));
|
||||
const catalog = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG);
|
||||
const installs = resolveBundledPluginInstalls(["fixture"], { catalogRoot: root, enforceCatalogRoot: true, env: {}, distributionPlugins: catalog });
|
||||
const resolver = createBundledPluginPackageResolver(installs);
|
||||
const registration = { pluginKey: manifest.id, packageName: "@paperclipai/plugin-image-fixture", version: manifest.version, manifestJson: manifest };
|
||||
return { root, packageRoot, manifest, catalog, installs, resolver, registration };
|
||||
}
|
||||
+9
-2
@@ -184,6 +184,7 @@ import {
|
||||
resolveBundledCatalogRoot,
|
||||
resolveBundledPluginInstalls,
|
||||
} from "./services/bundled-plugins.js";
|
||||
import { createBundledPluginPackageResolver } from "./services/bundled-plugin-package.js";
|
||||
import { readDistributionPluginCatalog, distributionPluginActivationGuard } from "./services/distribution-plugin-catalog.js";
|
||||
import {
|
||||
createPluginWorkerManager,
|
||||
@@ -636,6 +637,7 @@ export async function createApp(
|
||||
distributionPlugins,
|
||||
},
|
||||
);
|
||||
const bundledPackageResolver = managedAutoInstallKeys === null ? undefined : createBundledPluginPackageResolver(bundledPluginInstalls);
|
||||
const managedBundledPluginKeys =
|
||||
managedAutoInstallKeys !== null
|
||||
? bundledPluginInstalls.map((install) => install.pluginKey)
|
||||
@@ -906,7 +908,11 @@ export async function createApp(
|
||||
{
|
||||
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||
migrationDb: opts.pluginMigrationDb,
|
||||
assertPackageActivation: distributionPluginActivationGuard(bundledCatalogRoot, distributionPlugins, managedAutoInstallKeys),
|
||||
assertPackageActivation: bundledPackageResolver?.assertActivation ?? distributionPluginActivationGuard(bundledCatalogRoot, distributionPlugins, managedAutoInstallKeys),
|
||||
resolvePackageRoot: bundledPackageResolver?.resolve,
|
||||
prebuiltOnly: managedAutoInstallKeys !== null,
|
||||
enableLocalFilesystem: managedAutoInstallKeys === null,
|
||||
enableNpmDiscovery: managedAutoInstallKeys === null,
|
||||
},
|
||||
{
|
||||
workerManager,
|
||||
@@ -1015,6 +1021,7 @@ export async function createApp(
|
||||
app.use(
|
||||
pluginUiStaticRoutes(db, {
|
||||
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||
resolvePackageRoot: bundledPackageResolver?.resolve,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1300,7 +1307,7 @@ export async function createApp(
|
||||
// owns provisioning); self-hosted leaves an operator's uninstall alone.
|
||||
// Disabled plugins never start automatically. Added distribution permissions
|
||||
// still enter upgrade_pending so enabling them requires an operator decision.
|
||||
{ reinstallUninstalled: managedAutoInstallKeys !== null },
|
||||
{ reinstallUninstalled: managedAutoInstallKeys !== null, preservePackagePath: managedAutoInstallKeys !== null },
|
||||
)
|
||||
.then(() => loader.loadAll())
|
||||
.then((result) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { PluginRecord } from "@paperclipai/shared";
|
||||
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
@@ -192,6 +193,7 @@ export interface PluginUiStaticRouteOptions {
|
||||
* Defaults to the standard `~/.paperclip/plugins/` location.
|
||||
*/
|
||||
localPluginDir: string;
|
||||
resolvePackageRoot?: (plugin: PluginRecord) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,7 +306,7 @@ export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions
|
||||
|
||||
if (typeof devUiUrl === "string" && devUiUrl.length > 0) {
|
||||
// Dev proxy is only available in development mode
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (options.resolvePackageRoot || process.env.NODE_ENV === "production") {
|
||||
log.warn(
|
||||
{ pluginId: plugin.id },
|
||||
"plugin-ui-static: devUiUrl ignored in production",
|
||||
@@ -405,12 +407,15 @@ export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions
|
||||
}
|
||||
|
||||
// Step 3: Resolve the plugin's UI directory
|
||||
const uiDir = resolvePluginUiDir(
|
||||
options.localPluginDir,
|
||||
plugin.packageName,
|
||||
manifest.entrypoints.ui,
|
||||
plugin.packagePath,
|
||||
);
|
||||
let uiDir: string | null;
|
||||
try {
|
||||
uiDir = options.resolvePackageRoot
|
||||
? path.resolve(options.resolvePackageRoot(plugin), manifest.entrypoints.ui)
|
||||
: resolvePluginUiDir(options.localPluginDir, plugin.packageName, manifest.entrypoints.ui, plugin.packagePath);
|
||||
} catch {
|
||||
res.status(503).json({ error: "Plugin package is unavailable in the current image" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uiDir) {
|
||||
log.warn(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { PaperclipPluginManifestV1, PluginRecord } from "@paperclipai/shared";
|
||||
import type { ResolvedBundledPlugin } from "./bundled-plugins.js";
|
||||
import { assertDistributionManifestCapabilities } from "./distribution-plugin-catalog.js";
|
||||
|
||||
export type BundledPluginRegistration = Pick<PluginRecord, "pluginKey" | "packageName" | "version" | "manifestJson">;
|
||||
|
||||
export interface BundledPluginPackageIdentity {
|
||||
kind: "bundled_image";
|
||||
catalogKey: string;
|
||||
pluginKey: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
export type BundledPluginPackageInspection =
|
||||
| { compatible: true; identity: BundledPluginPackageIdentity }
|
||||
| { compatible: false; pluginKey: string; code: "incompatible_bundled_package" | "missing_manifest_evidence"; reason: string };
|
||||
|
||||
function containedEntrypoint(root: string, declared: unknown, directory: boolean): string {
|
||||
const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : "";
|
||||
if (!relative || path.isAbsolute(relative) || path.win32.isAbsolute(relative) || relative.includes("\\") || relative.split("/").length > 32 || relative.split("/").some((part) => !part || part === "." || part === "..")) {
|
||||
throw new Error("Bundled plugin entrypoint must stay inside its package");
|
||||
}
|
||||
const target = fs.realpathSync(path.join(root, relative));
|
||||
const resolvedRelative = path.relative(root, target);
|
||||
if (!resolvedRelative || resolvedRelative.startsWith("..") || path.isAbsolute(resolvedRelative)) {
|
||||
throw new Error("Bundled plugin entrypoint escapes its package");
|
||||
}
|
||||
const stat = fs.statSync(target);
|
||||
if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error("Bundled plugin entrypoint is not prebuilt");
|
||||
return relative;
|
||||
}
|
||||
|
||||
export function createBundledPluginPackageResolver(installs: readonly ResolvedBundledPlugin[]) {
|
||||
const byKey = new Map(installs.map((install) => [install.pluginKey, install]));
|
||||
|
||||
function read(install: ResolvedBundledPlugin) {
|
||||
const root = fs.realpathSync(install.localPath);
|
||||
if (root !== path.resolve(install.localPath)) throw new Error("Bundled plugin package root changed after catalog resolution");
|
||||
containedEntrypoint(root, "package.json", false);
|
||||
const metadataPath = path.join(root, "package.json");
|
||||
if (fs.statSync(metadataPath).size > 128 * 1024) throw new Error("Bundled plugin package metadata exceeds verification limits");
|
||||
const pkg = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
||||
if (typeof pkg.name !== "string" || typeof pkg.version !== "string" || !pkg.paperclipPlugin) {
|
||||
throw new Error("Bundled plugin package identity or entrypoints are missing");
|
||||
}
|
||||
if (install.distribution && pkg.version !== install.distribution.version) throw new Error("Bundled plugin package version does not match the catalog");
|
||||
const manifest = containedEntrypoint(root, pkg.paperclipPlugin.manifest, false);
|
||||
const worker = containedEntrypoint(root, pkg.paperclipPlugin.worker, false);
|
||||
const ui = pkg.paperclipPlugin.ui === undefined ? undefined : containedEntrypoint(root, pkg.paperclipPlugin.ui, true);
|
||||
if (install.distribution && (worker !== install.distribution.entrypoints.worker || ui !== install.distribution.entrypoints.ui)) {
|
||||
throw new Error("Bundled plugin entrypoints do not match the verified package");
|
||||
}
|
||||
return { root, pkg, manifest, worker, ui };
|
||||
}
|
||||
|
||||
function assertManifest(install: ResolvedBundledPlugin, manifest: PaperclipPluginManifestV1, previousManifest?: PaperclipPluginManifestV1) {
|
||||
const bundle = read(install);
|
||||
if (manifest.id !== install.pluginKey || manifest.version !== bundle.pkg.version) throw new Error("Bundled plugin manifest identity/version does not match the image");
|
||||
const worker = containedEntrypoint(bundle.root, manifest.entrypoints.worker, false);
|
||||
const ui = manifest.entrypoints.ui === undefined ? undefined : containedEntrypoint(bundle.root, manifest.entrypoints.ui, true);
|
||||
if (worker !== bundle.worker || ui !== bundle.ui) throw new Error("Bundled plugin manifest entrypoints do not match the image");
|
||||
assertDistributionManifestCapabilities(manifest);
|
||||
if (previousManifest) {
|
||||
const approved = new Set(previousManifest.capabilities);
|
||||
if (manifest.capabilities.some((capability) => !approved.has(capability))) throw new Error("Bundled plugin capabilities require approval");
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function resolve(plugin: BundledPluginRegistration): string {
|
||||
const install = byKey.get(plugin.pluginKey);
|
||||
if (!install) throw new Error(`Bundled plugin is absent or not selected in this deployment: ${plugin.pluginKey}`);
|
||||
const bundle = assertManifest(install, plugin.manifestJson);
|
||||
if (plugin.packageName !== bundle.pkg.name || plugin.version !== bundle.pkg.version) throw new Error("Bundled plugin registration identity/version does not match the image");
|
||||
return bundle.root;
|
||||
}
|
||||
|
||||
return {
|
||||
resolve,
|
||||
inspect(plugin: BundledPluginRegistration, verifiedManifest?: PaperclipPluginManifestV1): BundledPluginPackageInspection {
|
||||
try {
|
||||
resolve(plugin);
|
||||
const install = byKey.get(plugin.pluginKey)!;
|
||||
if (!verifiedManifest) return { compatible: false, pluginKey: plugin.pluginKey, code: "missing_manifest_evidence", reason: "Image manifest evidence is required" };
|
||||
assertManifest(install, verifiedManifest);
|
||||
if (!isDeepStrictEqual(verifiedManifest, plugin.manifestJson)) throw new Error("Approved plugin manifest does not match the image");
|
||||
return { compatible: true, identity: {
|
||||
kind: "bundled_image", catalogKey: install.key, pluginKey: plugin.pluginKey,
|
||||
packageName: plugin.packageName, version: plugin.version,
|
||||
...(install.distribution ? { digest: install.distribution.digest } : {}),
|
||||
} };
|
||||
} catch (error) {
|
||||
return { compatible: false, pluginKey: plugin.pluginKey, code: "incompatible_bundled_package", reason: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
assertActivation(input: { pluginKey?: string; packageRoot: string; manifest?: PaperclipPluginManifestV1; previousManifest?: PaperclipPluginManifestV1 }) {
|
||||
const install = input.pluginKey ? byKey.get(input.pluginKey) : installs.find((candidate) => candidate.localPath === input.packageRoot);
|
||||
if (!install || path.resolve(input.packageRoot) !== install.localPath) throw new Error("Bundled plugin is absent or not selected in this deployment");
|
||||
read(install);
|
||||
if (input.manifest) assertManifest(install, input.manifest, input.previousManifest);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ export function resolveBundledPluginInstalls(
|
||||
`bundled plugin "${key}" resolves to "${localPath}", outside the bundled catalog root "${opts.catalogRoot}"; refusing to start`,
|
||||
);
|
||||
}
|
||||
resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath });
|
||||
resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath: opts.enforceCatalogRoot ? canonicalize(localPath) : localPath });
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -269,6 +269,7 @@ async function reconcileBundledPluginManifest(
|
||||
deps: BundledPluginProvisionerDeps,
|
||||
bundleManifestExists: (localPath: string) => boolean,
|
||||
verifiedManifest?: PaperclipPluginManifestV1,
|
||||
preservePackagePath = false,
|
||||
): Promise<"upgrade_pending" | undefined> {
|
||||
try {
|
||||
// Managed reinstalls take the install path instead; this branch means the
|
||||
@@ -283,7 +284,7 @@ async function reconcileBundledPluginManifest(
|
||||
const approved = new Set(existing.manifestJson.capabilities ?? []);
|
||||
requiresApproval = bundleManifest.capabilities.some((capability) => !approved.has(capability));
|
||||
}
|
||||
const rebindPackage = install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled";
|
||||
const rebindPackage = !preservePackagePath && install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled";
|
||||
const refreshDistribution = install.distribution && !isDeepStrictEqual(bundleManifest, existing.manifestJson);
|
||||
if (bundleManifest.version === existing.version && !rebindPackage && !requiresApproval && !refreshDistribution) return;
|
||||
await deps.registry.update(existing.id, {
|
||||
@@ -389,7 +390,7 @@ async function reenableErroredBundledPlugin(
|
||||
export async function ensureBundledPlugins(
|
||||
installs: readonly ResolvedBundledPlugin[],
|
||||
deps: BundledPluginProvisionerDeps,
|
||||
opts: { reinstallUninstalled: boolean },
|
||||
opts: { reinstallUninstalled: boolean; preservePackagePath?: boolean },
|
||||
): Promise<void> {
|
||||
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
|
||||
for (const install of installs) {
|
||||
@@ -411,7 +412,7 @@ export async function ensureBundledPlugins(
|
||||
// existing install, because the auto-install below skips a present
|
||||
// plugin. Distribution entries also replace a legacy/npm package path
|
||||
// before loadAll resolves the worker. Configuration and status stay put.
|
||||
const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest);
|
||||
const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest, opts.preservePackagePath);
|
||||
if (reconciledStatus === "upgrade_pending") continue;
|
||||
if (existing.status === "error") {
|
||||
await reenableErroredBundledPlugin(existing, install, deps);
|
||||
|
||||
@@ -31,7 +31,7 @@ import { execFile } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { isDeepStrictEqual, promisify } from "node:util";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
@@ -271,6 +271,8 @@ function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[
|
||||
* Options for the plugin loader service.
|
||||
*/
|
||||
export interface PluginLoaderOptions {
|
||||
resolvePackageRoot?: (plugin: PluginRecord) => string;
|
||||
prebuiltOnly?: boolean;
|
||||
/** Image-owned deployment policy, checked before importing code and starting workers. */
|
||||
assertPackageActivation?: (input: {
|
||||
pluginKey?: string;
|
||||
@@ -1157,6 +1159,8 @@ export function pluginLoader(
|
||||
enableLocalFilesystem = true,
|
||||
enableNpmDiscovery = true,
|
||||
assertPackageActivation,
|
||||
resolvePackageRoot,
|
||||
prebuiltOnly = false,
|
||||
} = options;
|
||||
|
||||
const registry = pluginRegistryService(db);
|
||||
@@ -1211,6 +1215,7 @@ export function pluginLoader(
|
||||
installOptions: PluginInstallOptions,
|
||||
): Promise<DiscoveredPlugin> {
|
||||
const { packageName, localPath, version, installDir } = installOptions;
|
||||
if (prebuiltOnly && !localPath) throw new Error("Managed plugins must come from the current image catalog");
|
||||
|
||||
if (!packageName && !localPath) {
|
||||
throw new Error("Either packageName or localPath must be provided");
|
||||
@@ -1286,7 +1291,7 @@ export function pluginLoader(
|
||||
const pkgJson = await readPackageJson(resolvedPackagePath);
|
||||
if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`);
|
||||
|
||||
if (localPath) {
|
||||
if (localPath && !prebuiltOnly) {
|
||||
await ensureLocalPluginBuilt(resolvedPackagePath, pkgJson);
|
||||
}
|
||||
|
||||
@@ -1398,7 +1403,7 @@ export function pluginLoader(
|
||||
}
|
||||
|
||||
assertPackageActivation?.({ packageRoot, installedPackagePath: plugin.packagePath, pluginKey: plugin.pluginKey, manifest, previousManifest: plugin.manifestJson });
|
||||
if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) {
|
||||
if (isDeepStrictEqual(manifest, plugin.manifestJson)) {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
@@ -1749,6 +1754,7 @@ export function pluginLoader(
|
||||
packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined,
|
||||
},
|
||||
manifest,
|
||||
{ idempotent: prebuiltOnly },
|
||||
);
|
||||
|
||||
if (!installed) {
|
||||
@@ -1817,7 +1823,7 @@ export function pluginLoader(
|
||||
// For local-path installs, fall back to the stored packagePath so
|
||||
// `upgradePlugin` can re-read the manifest from disk without needing
|
||||
// the caller to re-supply the path every time.
|
||||
localPath = plugin.packagePath ?? undefined,
|
||||
localPath = resolvePackageRoot ? resolvePackageRoot(plugin) : plugin.packagePath ?? undefined,
|
||||
version,
|
||||
} = upgradeOptions;
|
||||
|
||||
@@ -1887,6 +1893,7 @@ export function pluginLoader(
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async cleanupInstallArtifacts(plugin: PluginRecord): Promise<void> {
|
||||
if (prebuiltOnly) return;
|
||||
const managedTargets = new Set<string>();
|
||||
const managedNodeModulesDir = resolveManagedInstallPackageDir(localPluginDir, plugin.packageName);
|
||||
const directManagedDir = path.join(localPluginDir, plugin.packageName);
|
||||
@@ -2262,12 +2269,14 @@ export function pluginLoader(
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Resolve worker entrypoint
|
||||
// ------------------------------------------------------------------
|
||||
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
|
||||
const packageRoot = resolvePackageRoot ? resolvePackageRoot(activePlugin) : resolvePluginPackageRoot(activePlugin, localPluginDir);
|
||||
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath });
|
||||
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
|
||||
manifest = activePlugin.manifestJson;
|
||||
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath, manifest });
|
||||
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
|
||||
const workerEntrypoint = resolvePackageRoot
|
||||
? path.resolve(packageRoot, manifest.entrypoints.worker)
|
||||
: resolveWorkerEntrypoint(activePlugin, localPluginDir);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Apply restricted database migrations before worker startup
|
||||
@@ -2334,7 +2343,7 @@ export function pluginLoader(
|
||||
env: buildPluginWorkerEnv({
|
||||
manifest,
|
||||
packageName: activePlugin.packageName,
|
||||
packagePath: activePlugin.packagePath,
|
||||
packagePath: packageRoot,
|
||||
instanceInfo,
|
||||
}),
|
||||
// Authorize the worker to act on each configured company from its
|
||||
@@ -2348,7 +2357,7 @@ export function pluginLoader(
|
||||
// Repo-local plugin installs can resolve workspace TS sources at runtime
|
||||
// (for example @paperclipai/shared exports). Run those workers through
|
||||
// the tsx loader so first-party example plugins work in development.
|
||||
if (activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) {
|
||||
if (!prebuiltOnly && activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) {
|
||||
workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { writePluginJobChange } from "./plugin-job-work-notifications.js";
|
||||
import { asc, eq, isNull, ne, sql, and } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -31,6 +32,10 @@ import type {
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
function sameBundledPluginRegistration(existing: typeof plugins.$inferSelect, packageName: string, manifest: PaperclipPluginManifestV1): boolean {
|
||||
return existing.pluginKey === manifest.id && existing.packageName === packageName && existing.version === manifest.version && isDeepStrictEqual(existing.manifestJson, manifest);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -140,10 +145,11 @@ export function pluginRegistryService(db: Db) {
|
||||
* manifest from the package. This method persists the plugin row and
|
||||
* assigns the next install order.
|
||||
*/
|
||||
install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1) => {
|
||||
install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1, options: { idempotent?: boolean } = {}) => {
|
||||
const existing = await getByKey(manifest.id);
|
||||
if (existing) {
|
||||
if (existing.status !== "uninstalled") {
|
||||
if (options.idempotent && sameBundledPluginRegistration(existing, input.packageName, manifest)) return existing;
|
||||
throw conflict(`Plugin already installed: ${manifest.id}`);
|
||||
}
|
||||
|
||||
@@ -153,7 +159,7 @@ export function pluginRegistryService(db: Db) {
|
||||
.update(plugins)
|
||||
.set({
|
||||
packageName: input.packageName,
|
||||
packagePath: input.packagePath ?? null,
|
||||
packagePath: options.idempotent ? existing.packagePath : input.packagePath ?? null,
|
||||
version: manifest.version,
|
||||
apiVersion: manifest.apiVersion,
|
||||
categories: manifest.categories,
|
||||
@@ -170,7 +176,7 @@ export function pluginRegistryService(db: Db) {
|
||||
const installOrder = await nextInstallOrder();
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
const insertion = db
|
||||
.insert(plugins)
|
||||
.values({
|
||||
pluginKey: manifest.id,
|
||||
@@ -182,9 +188,14 @@ export function pluginRegistryService(db: Db) {
|
||||
status: "installed" as PluginStatus,
|
||||
installOrder,
|
||||
packagePath: input.packagePath ?? null,
|
||||
})
|
||||
.returning();
|
||||
return rows[0];
|
||||
});
|
||||
const rows = await (options.idempotent
|
||||
? insertion.onConflictDoNothing({ target: plugins.pluginKey })
|
||||
: insertion).returning();
|
||||
if (rows[0]) return rows[0];
|
||||
const concurrent = await getByKey(manifest.id);
|
||||
if (concurrent && concurrent.status !== "uninstalled" && sameBundledPluginRegistration(concurrent, input.packageName, manifest)) return concurrent;
|
||||
throw conflict(`Plugin registration differs from the current image: ${manifest.id}`);
|
||||
} catch (error) {
|
||||
if (isPluginKeyConflict(error)) {
|
||||
throw conflict(`Plugin already installed: ${manifest.id}`);
|
||||
|
||||
Reference in new issue
Block a user