mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Expand plugin host surface (#5205)
## 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>
This commit is contained in:
parent
d6bee62f02
commit
3c73ed26b5
89 changed files with 27516 additions and 914 deletions
564
server/src/services/plugin-local-folders.ts
Normal file
564
server/src/services/plugin-local-folders.ts
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue