mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The plugin system is the extension boundary for optional product capabilities > - Rich plugins need more than a worker entrypoint: they need scoped database storage, local project folders, managed agents/routines, host navigation, and reusable UI components > - The LLM Wiki work exposed those missing host surfaces while keeping plugin code outside the core control plane > - This pull request expands the core plugin host, SDK, server APIs, and UI bridge so plugins can declare and use those surfaces > - The benefit is that future plugins can integrate with Paperclip through documented, validated contracts instead of bespoke server or UI imports ## What Changed - Added plugin-managed database namespaces and migration tracking, including Drizzle schema/migration files and SQL validation for namespace isolation. - Added server support for plugin local folders, managed agents, managed routines, scoped plugin APIs, and plugin operation visibility. - Expanded shared plugin manifest/types/validators and SDK host/testing/UI exports for richer plugin surfaces. - Added reusable UI pieces for file trees, managed routines, resizable sidebars, route sidebars, and plugin bridge initialization. - Updated plugin docs and example plugins to use the expanded host and SDK surface. ## Verification - `pnpm install --frozen-lockfile` - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/validators/plugin.test.ts server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-local-folders.test.ts server/src/__tests__/plugin-managed-agents.test.ts server/src/__tests__/plugin-managed-routines.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx ui/src/components/ResizableSidebarPane.test.tsx ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed: 11 files, 67 tests. - Confirmed this PR changes 89 files and does not include `pnpm-lock.yaml` or `.github/workflows/*`. ## Risks - Medium: this expands plugin host contracts across db/shared/server/ui and includes a new core migration (`0076_useful_elektra.sql`). - The plugin database namespace validator is intentionally restrictive; plugin authors may need follow-up affordances for SQL patterns that remain blocked. - Merge this before the LLM Wiki plugin PR so the plugin can resolve the new SDK and host APIs. > 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, GPT-5 coding agent, tool-enabled shell/git/GitHub workflow. Context window size was not exposed by the runtime. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
564 lines
19 KiB
TypeScript
564 lines
19 KiB
TypeScript
import { constants as fsConstants, promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import type {
|
|
PluginLocalFolderDeclaration,
|
|
PluginLocalFolderEntry,
|
|
PluginLocalFolderListing,
|
|
PluginLocalFolderProblem,
|
|
PluginLocalFolderStatus,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import { badRequest, forbidden, notFound } from "../errors.js";
|
|
|
|
export interface StoredPluginLocalFolderConfig {
|
|
path: string;
|
|
access?: "read" | "readWrite";
|
|
requiredDirectories?: string[];
|
|
requiredFiles?: string[];
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export interface PluginLocalFolderSettingsJson {
|
|
localFolders?: Record<string, StoredPluginLocalFolderConfig>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const LOCAL_FOLDER_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
|
|
|
|
function problem(
|
|
code: PluginLocalFolderProblem["code"],
|
|
message: string,
|
|
problemPath?: string,
|
|
): PluginLocalFolderProblem {
|
|
return { code, message, path: problemPath };
|
|
}
|
|
|
|
export function assertPluginLocalFolderKey(folderKey: string) {
|
|
if (!LOCAL_FOLDER_KEY_PATTERN.test(folderKey)) {
|
|
throw badRequest("folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens");
|
|
}
|
|
}
|
|
|
|
export function findLocalFolderDeclaration(
|
|
declarations: PluginLocalFolderDeclaration[] | undefined,
|
|
folderKey: string,
|
|
) {
|
|
return declarations?.find((declaration) => declaration.folderKey === folderKey) ?? null;
|
|
}
|
|
|
|
export function requireLocalFolderDeclaration(
|
|
declarations: PluginLocalFolderDeclaration[] | undefined,
|
|
folderKey: string,
|
|
) {
|
|
assertPluginLocalFolderKey(folderKey);
|
|
const declaration = findLocalFolderDeclaration(declarations, folderKey);
|
|
if (!declaration) {
|
|
throw badRequest("Local folder key is not declared by this plugin manifest");
|
|
}
|
|
return declaration;
|
|
}
|
|
|
|
function normalizeRelativePath(relativePath: string): string {
|
|
if (
|
|
!relativePath ||
|
|
path.isAbsolute(relativePath) ||
|
|
relativePath.includes("\\") ||
|
|
relativePath.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
) {
|
|
throw forbidden("Local folder relative paths must stay inside the configured root");
|
|
}
|
|
return relativePath;
|
|
}
|
|
|
|
function validateRequiredPath(pathValue: string, label: string): string {
|
|
try {
|
|
return normalizeRelativePath(pathValue);
|
|
} catch {
|
|
throw badRequest(`${label} must contain only relative paths without traversal, empty segments, or backslashes`);
|
|
}
|
|
}
|
|
|
|
function normalizeListRelativePath(relativePath: string | null | undefined): string | null {
|
|
const trimmed = relativePath?.trim();
|
|
if (!trimmed) return null;
|
|
return normalizeRelativePath(trimmed);
|
|
}
|
|
|
|
function normalizeMaxEntries(value: number | undefined): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return 1000;
|
|
return Math.max(1, Math.min(5000, Math.floor(value)));
|
|
}
|
|
|
|
function mergeFolderConfig(
|
|
declaration: PluginLocalFolderDeclaration | null,
|
|
stored: StoredPluginLocalFolderConfig | null,
|
|
override?: Partial<StoredPluginLocalFolderConfig>,
|
|
): StoredPluginLocalFolderConfig | null {
|
|
const pathValue = override?.path ?? stored?.path;
|
|
if (!pathValue) return null;
|
|
return {
|
|
path: pathValue,
|
|
access: declaration?.access ?? override?.access ?? stored?.access ?? "readWrite",
|
|
requiredDirectories:
|
|
declaration?.requiredDirectories ?? override?.requiredDirectories ?? stored?.requiredDirectories ?? [],
|
|
requiredFiles:
|
|
declaration?.requiredFiles ?? override?.requiredFiles ?? stored?.requiredFiles ?? [],
|
|
updatedAt: stored?.updatedAt,
|
|
};
|
|
}
|
|
|
|
export function getStoredLocalFolders(settingsJson: Record<string, unknown> | null | undefined) {
|
|
const folders = (settingsJson as PluginLocalFolderSettingsJson | undefined)?.localFolders;
|
|
if (!folders || typeof folders !== "object") return {};
|
|
return folders;
|
|
}
|
|
|
|
export function setStoredLocalFolder(
|
|
settingsJson: Record<string, unknown> | null | undefined,
|
|
folderKey: string,
|
|
config: StoredPluginLocalFolderConfig,
|
|
): PluginLocalFolderSettingsJson {
|
|
return {
|
|
...(settingsJson ?? {}),
|
|
localFolders: {
|
|
...getStoredLocalFolders(settingsJson),
|
|
[folderKey]: {
|
|
...config,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function inspectPluginLocalFolder(input: {
|
|
folderKey: string;
|
|
declaration?: PluginLocalFolderDeclaration | null;
|
|
storedConfig?: StoredPluginLocalFolderConfig | null;
|
|
overrideConfig?: Partial<StoredPluginLocalFolderConfig>;
|
|
}): Promise<PluginLocalFolderStatus> {
|
|
assertPluginLocalFolderKey(input.folderKey);
|
|
const config = mergeFolderConfig(
|
|
input.declaration ?? null,
|
|
input.storedConfig ?? null,
|
|
input.overrideConfig,
|
|
);
|
|
const access = config?.access ?? input.declaration?.access ?? "readWrite";
|
|
const requiredDirectories = (config?.requiredDirectories ?? []).map((item) =>
|
|
validateRequiredPath(item, "requiredDirectories"),
|
|
);
|
|
const requiredFiles = (config?.requiredFiles ?? []).map((item) =>
|
|
validateRequiredPath(item, "requiredFiles"),
|
|
);
|
|
const checkedAt = new Date().toISOString();
|
|
|
|
if (!config?.path) {
|
|
return {
|
|
folderKey: input.folderKey,
|
|
configured: false,
|
|
path: null,
|
|
realPath: null,
|
|
access,
|
|
readable: false,
|
|
writable: false,
|
|
requiredDirectories,
|
|
requiredFiles,
|
|
missingDirectories: requiredDirectories,
|
|
missingFiles: requiredFiles,
|
|
healthy: false,
|
|
problems: [problem("not_configured", "No local folder path is configured.")],
|
|
checkedAt,
|
|
};
|
|
}
|
|
|
|
const configuredPath = path.resolve(config.path);
|
|
const problems: PluginLocalFolderProblem[] = [];
|
|
const missingDirectories: string[] = [];
|
|
const missingFiles: string[] = [];
|
|
const markRequiredPathsMissing = () => {
|
|
missingDirectories.push(...requiredDirectories);
|
|
missingFiles.push(...requiredFiles);
|
|
};
|
|
let realPath: string | null = null;
|
|
let readable = false;
|
|
let writable = false;
|
|
|
|
if (!path.isAbsolute(config.path)) {
|
|
problems.push(problem("not_absolute", "Local folder path must be absolute.", config.path));
|
|
}
|
|
|
|
try {
|
|
const stat = await fs.stat(configuredPath);
|
|
if (!stat.isDirectory()) {
|
|
problems.push(problem("not_directory", "Configured local folder path is not a directory.", configuredPath));
|
|
markRequiredPathsMissing();
|
|
} else {
|
|
realPath = await fs.realpath(configuredPath);
|
|
try {
|
|
await fs.access(realPath, fsConstants.R_OK);
|
|
readable = true;
|
|
} catch {
|
|
problems.push(problem("not_readable", "Configured local folder is not readable.", configuredPath));
|
|
}
|
|
|
|
if (access === "readWrite") {
|
|
try {
|
|
await fs.access(realPath, fsConstants.W_OK);
|
|
const probePath = path.join(realPath, `.paperclip-local-folder-probe-${process.pid}-${Date.now()}`);
|
|
await fs.writeFile(probePath, "");
|
|
await fs.rm(probePath, { force: true });
|
|
writable = true;
|
|
} catch {
|
|
problems.push(problem("not_writable", "Configured local folder is not writable.", configuredPath));
|
|
}
|
|
}
|
|
|
|
for (const requiredDir of requiredDirectories) {
|
|
const requiredStatus = await inspectChildPath(realPath, requiredDir, "directory");
|
|
if (!requiredStatus.exists) {
|
|
missingDirectories.push(requiredDir);
|
|
problems.push(problem("missing_directory", "Required directory is missing.", requiredDir));
|
|
} else if (!requiredStatus.contained) {
|
|
problems.push(problem("symlink_escape", "Required directory escapes the configured root.", requiredDir));
|
|
} else if (!requiredStatus.matchesKind) {
|
|
missingDirectories.push(requiredDir);
|
|
problems.push(problem("missing_directory", "Required path is not a directory.", requiredDir));
|
|
}
|
|
}
|
|
|
|
for (const requiredFile of requiredFiles) {
|
|
const requiredStatus = await inspectChildPath(realPath, requiredFile, "file");
|
|
if (!requiredStatus.exists) {
|
|
missingFiles.push(requiredFile);
|
|
problems.push(problem("missing_file", "Required file is missing.", requiredFile));
|
|
} else if (!requiredStatus.contained) {
|
|
problems.push(problem("symlink_escape", "Required file escapes the configured root.", requiredFile));
|
|
} else if (!requiredStatus.matchesKind) {
|
|
missingFiles.push(requiredFile);
|
|
problems.push(problem("missing_file", "Required path is not a file.", requiredFile));
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
problems.push(problem(code === "ENOENT" ? "missing" : "not_readable", "Configured local folder cannot be inspected.", configuredPath));
|
|
if (code === "ENOENT") {
|
|
markRequiredPathsMissing();
|
|
}
|
|
}
|
|
|
|
return {
|
|
folderKey: input.folderKey,
|
|
configured: true,
|
|
path: configuredPath,
|
|
realPath,
|
|
access,
|
|
readable,
|
|
writable: access === "read" ? false : writable,
|
|
requiredDirectories,
|
|
requiredFiles,
|
|
missingDirectories,
|
|
missingFiles,
|
|
healthy:
|
|
problems.length === 0 &&
|
|
readable &&
|
|
(access === "read" || writable),
|
|
problems,
|
|
checkedAt,
|
|
};
|
|
}
|
|
|
|
function isInsideRoot(rootRealPath: string, candidateRealPath: string) {
|
|
const relative = path.relative(rootRealPath, candidateRealPath);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
async function assertPathInsideRoot(rootRealPath: string, candidatePath: string) {
|
|
const candidateRealPath = await fs.realpath(candidatePath);
|
|
if (!isInsideRoot(rootRealPath, candidateRealPath)) {
|
|
throw forbidden("Local folder symlink escape is not allowed");
|
|
}
|
|
return candidateRealPath;
|
|
}
|
|
|
|
async function ensureDirectoryInsideRoot(rootRealPath: string, relativePath: string) {
|
|
const normalized = normalizeRelativePath(relativePath);
|
|
const segments = normalized.split("/");
|
|
let currentRealPath = rootRealPath;
|
|
|
|
for (const segment of segments) {
|
|
const nextPath = path.join(currentRealPath, segment);
|
|
try {
|
|
const stat = await fs.stat(nextPath);
|
|
if (!stat.isDirectory()) {
|
|
throw badRequest("Required directory path exists but is not a directory");
|
|
}
|
|
} catch (error) {
|
|
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
if (code !== "ENOENT") throw error;
|
|
await fs.mkdir(nextPath);
|
|
}
|
|
|
|
const nextRealPath = await fs.realpath(nextPath);
|
|
if (!isInsideRoot(rootRealPath, nextRealPath)) {
|
|
throw forbidden("Local folder symlink escape is not allowed");
|
|
}
|
|
currentRealPath = nextRealPath;
|
|
}
|
|
}
|
|
|
|
export async function preparePluginLocalFolder(input: {
|
|
folderKey: string;
|
|
declaration?: PluginLocalFolderDeclaration | null;
|
|
storedConfig?: StoredPluginLocalFolderConfig | null;
|
|
overrideConfig?: Partial<StoredPluginLocalFolderConfig>;
|
|
}) {
|
|
assertPluginLocalFolderKey(input.folderKey);
|
|
const config = mergeFolderConfig(
|
|
input.declaration ?? null,
|
|
input.storedConfig ?? null,
|
|
input.overrideConfig,
|
|
);
|
|
const access = config?.access ?? input.declaration?.access ?? "readWrite";
|
|
if (!config?.path || access !== "readWrite" || !path.isAbsolute(config.path)) return;
|
|
|
|
const configuredPath = path.resolve(config.path);
|
|
try {
|
|
const stat = await fs.stat(configuredPath);
|
|
if (!stat.isDirectory()) return;
|
|
} catch (error) {
|
|
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
if (code !== "ENOENT") return;
|
|
try {
|
|
await fs.mkdir(configuredPath, { recursive: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
const rootRealPath = await fs.realpath(configuredPath);
|
|
|
|
for (const requiredDir of config.requiredDirectories ?? []) {
|
|
await ensureDirectoryInsideRoot(rootRealPath, validateRequiredPath(requiredDir, "requiredDirectories"));
|
|
}
|
|
}
|
|
|
|
async function inspectChildPath(
|
|
rootRealPath: string,
|
|
relativePath: string,
|
|
kind: "directory" | "file",
|
|
) {
|
|
let resolvedPath: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
|
|
try {
|
|
resolvedPath = await resolvePluginLocalFolderPath(rootRealPath, relativePath, {
|
|
mustExist: true,
|
|
allowMissingLeaf: true,
|
|
});
|
|
} catch {
|
|
return { exists: true, contained: false, matchesKind: false };
|
|
}
|
|
if (!resolvedPath.exists) {
|
|
return { exists: false, contained: true, matchesKind: false };
|
|
}
|
|
const stat = await fs.stat(resolvedPath.realPath);
|
|
return {
|
|
exists: true,
|
|
contained: true,
|
|
matchesKind: kind === "directory" ? stat.isDirectory() : stat.isFile(),
|
|
};
|
|
}
|
|
|
|
export async function resolvePluginLocalFolderPath(
|
|
rootPath: string,
|
|
relativePath: string,
|
|
options?: { mustExist?: boolean; allowMissingLeaf?: boolean },
|
|
) {
|
|
const normalized = normalizeRelativePath(relativePath);
|
|
const rootRealPath = await fs.realpath(rootPath);
|
|
const absolutePath = path.resolve(rootRealPath, normalized);
|
|
const relativeFromRoot = path.relative(rootRealPath, absolutePath);
|
|
if (relativeFromRoot.startsWith("..") || path.isAbsolute(relativeFromRoot)) {
|
|
throw forbidden("Local folder path traversal is not allowed");
|
|
}
|
|
|
|
try {
|
|
const realPath = await fs.realpath(absolutePath);
|
|
const realRelative = path.relative(rootRealPath, realPath);
|
|
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
|
|
throw forbidden("Local folder symlink escape is not allowed");
|
|
}
|
|
return { absolutePath, realPath, exists: true };
|
|
} catch (error) {
|
|
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
if (code !== "ENOENT" || options?.mustExist) {
|
|
if (options?.allowMissingLeaf && code === "ENOENT") {
|
|
return { absolutePath, realPath: absolutePath, exists: false };
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const parentRealPath = await fs.realpath(path.dirname(absolutePath));
|
|
const parentRelative = path.relative(rootRealPath, parentRealPath);
|
|
if (parentRelative.startsWith("..") || path.isAbsolute(parentRelative)) {
|
|
throw forbidden("Local folder symlink escape is not allowed");
|
|
}
|
|
return { absolutePath, realPath: absolutePath, exists: false };
|
|
}
|
|
}
|
|
|
|
export async function readPluginLocalFolderText(rootPath: string, relativePath: string) {
|
|
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath, { mustExist: true });
|
|
const stat = await fs.stat(resolved.realPath);
|
|
if (!stat.isFile()) {
|
|
throw badRequest("Local folder read target must be a file");
|
|
}
|
|
return fs.readFile(resolved.realPath, "utf8");
|
|
}
|
|
|
|
export async function listPluginLocalFolderEntries(
|
|
rootPath: string,
|
|
options: { relativePath?: string | null; recursive?: boolean; maxEntries?: number } = {},
|
|
): Promise<PluginLocalFolderListing> {
|
|
const rootRealPath = await fs.realpath(rootPath);
|
|
const relativePath = normalizeListRelativePath(options.relativePath);
|
|
const target = relativePath
|
|
? await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true })
|
|
: { absolutePath: rootRealPath, realPath: rootRealPath, exists: true };
|
|
const targetStat = await fs.stat(target.realPath);
|
|
if (!targetStat.isDirectory()) {
|
|
throw badRequest("Local folder list target must be a directory");
|
|
}
|
|
|
|
const maxEntries = normalizeMaxEntries(options.maxEntries);
|
|
const entries: PluginLocalFolderEntry[] = [];
|
|
let truncated = false;
|
|
|
|
const visit = async (directoryRealPath: string, directoryRelativePath: string | null) => {
|
|
if (truncated) return;
|
|
const dirents = await fs.readdir(directoryRealPath, { withFileTypes: true });
|
|
dirents.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
for (const dirent of dirents) {
|
|
if (entries.length >= maxEntries) {
|
|
truncated = true;
|
|
return;
|
|
}
|
|
|
|
const childRelativePath = directoryRelativePath ? `${directoryRelativePath}/${dirent.name}` : dirent.name;
|
|
let resolvedChild: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
|
|
try {
|
|
resolvedChild = await resolvePluginLocalFolderPath(rootRealPath, childRelativePath, { mustExist: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const stat = await fs.stat(resolvedChild.realPath).catch(() => null);
|
|
if (!stat) continue;
|
|
const kind = stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null;
|
|
if (!kind) continue;
|
|
|
|
entries.push({
|
|
path: childRelativePath,
|
|
name: dirent.name,
|
|
kind,
|
|
size: kind === "file" ? stat.size : null,
|
|
modifiedAt: stat.mtime.toISOString(),
|
|
});
|
|
|
|
if (options.recursive && kind === "directory") {
|
|
await visit(resolvedChild.realPath, childRelativePath);
|
|
if (truncated) return;
|
|
}
|
|
}
|
|
};
|
|
|
|
await visit(target.realPath, relativePath);
|
|
return {
|
|
folderKey: "list-result",
|
|
relativePath,
|
|
entries,
|
|
truncated,
|
|
};
|
|
}
|
|
|
|
export async function writePluginLocalFolderTextAtomic(
|
|
rootPath: string,
|
|
relativePath: string,
|
|
contents: string,
|
|
) {
|
|
const rootRealPath = await fs.realpath(rootPath);
|
|
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath);
|
|
await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true });
|
|
await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath));
|
|
const tempPath = path.join(
|
|
path.dirname(resolved.absolutePath),
|
|
`.paperclip-${path.basename(resolved.absolutePath)}-${process.pid}-${randomUUID()}.tmp`,
|
|
);
|
|
let tempCreated = false;
|
|
try {
|
|
const handle = await fs.open(tempPath, "wx");
|
|
tempCreated = true;
|
|
try {
|
|
await assertPathInsideRoot(rootRealPath, tempPath);
|
|
await handle.writeFile(contents, "utf8");
|
|
await handle.sync();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
} catch (error) {
|
|
if (tempCreated) {
|
|
await fs.rm(tempPath, { force: true });
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
await resolvePluginLocalFolderPath(rootRealPath, relativePath);
|
|
await fs.rename(tempPath, resolved.absolutePath);
|
|
await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true });
|
|
} catch (error) {
|
|
await fs.rm(tempPath, { force: true });
|
|
throw error;
|
|
}
|
|
|
|
if (process.platform !== "win32") {
|
|
const dirHandle = await fs.open(path.dirname(resolved.absolutePath), "r");
|
|
try {
|
|
await dirHandle.sync();
|
|
} finally {
|
|
await dirHandle.close();
|
|
}
|
|
}
|
|
|
|
return inspectPluginLocalFolder({
|
|
folderKey: "write-result",
|
|
storedConfig: {
|
|
path: rootPath,
|
|
access: "readWrite",
|
|
},
|
|
});
|
|
}
|
|
|
|
export function defaultLocalFolderBasePath(pluginKey: string, companyId: string) {
|
|
return path.join(os.homedir(), ".paperclip", "plugin-data", companyId, pluginKey);
|
|
}
|
|
|
|
export function assertConfiguredLocalFolder(status: PluginLocalFolderStatus) {
|
|
if (!status.configured || !status.realPath || !status.readable) {
|
|
throw notFound("Local folder is not configured or readable");
|
|
}
|
|
if (!status.healthy) {
|
|
throw badRequest("Local folder is not healthy");
|
|
}
|
|
}
|
|
|
|
export function assertWritableConfiguredLocalFolder(status: PluginLocalFolderStatus) {
|
|
if (!status.configured || !status.realPath || !status.readable) {
|
|
throw notFound("Local folder is not configured or readable");
|
|
}
|
|
const onlyMissingRequiredPaths = status.problems.every((item) =>
|
|
item.code === "missing_directory" || item.code === "missing_file"
|
|
);
|
|
if (!status.healthy && !onlyMissingRequiredPaths) {
|
|
throw badRequest("Local folder is not healthy");
|
|
}
|
|
}
|