0096b56a1c
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The plugin system needs host contracts and runtime support before large plugins can integrate cleanly. > - The source branch mixed the LLM Wiki package with supporting host/runtime work, managed plugin skills, root-level storage spaces, and a bookmarks reference plugin. > - [PAP-9173](/PAP/issues/PAP-9173) asked for the current branch to be split by file boundary: plugin package separately from everything else. > - [PAP-9188](/PAP/issues/PAP-9188) clarified that LLM Wiki may have plugin-local spaces, but Paperclip core should not reorganize top-level local storage into spaces. > - Follow-up review clarified that the bookmarks example should not ship in this PR either. > - This pull request contains the non-`packages/plugins/plugin-llm-wiki/` host/runtime work, keeps runtime state under the selected Paperclip instance root, and no longer includes the bookmarks example. ## What Changed - Added/updated plugin host contracts, SDK types, worker RPC plumbing, managed plugin skill support, and related server tests. - Removed the bookmarks example plugin package and its bundled-example/workspace references. - Removed the root-level local spaces CLI/migration surface and restored instance-root runtime defaults for config, db, logs, storage, secrets, workspaces, projects, and adapter homes. - Replaced shared root `space-paths` helpers with `home-paths` helpers for core runtime storage. - Tightened stranded recovery unique-conflict detection so concurrent recovery scans reuse the raced recovery issue when Postgres errors are wrapped. - Kept `packages/plugins/plugin-llm-wiki/` out of this PR diff; plugin-local spaces remain in the stacked plugin-only PR. ## Verification - `pnpm exec vitest run cli/src/__tests__/data-dir.test.ts cli/src/__tests__/home-paths.test.ts cli/src/__tests__/onboard.test.ts packages/shared/src/home-paths.test.ts packages/db/src/runtime-config.test.ts server/src/__tests__/agent-instructions-service.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/codex-local-execute.test.ts` - `pnpm exec vitest run packages/db/src/runtime-config.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts -t "reuses the raced stranded recovery issue"` skipped locally because embedded Postgres did not initialize on this macOS temp host; the code path was typechecked and is covered by Linux CI. - Boundary check: no core references remain for `PAPERCLIP_SPACE_ID`, `spaces migrate-default`, `@paperclipai/shared/space-paths`, `registerSpacesCommands`, or the removed bookmarks example. - Previous PR head `4f23e034` had green GitHub checks: `verify`, all four serialized server shards, `e2e`, `Canary Dry Run`, `policy`, Snyk, and `Greptile Review`. Current head `582f466d` is re-running checks after the bookmarks deletion. ## Risks - Plugin host changes touch shared runtime paths, so regressions would most likely appear in adapter startup, plugin loading, or local dev path defaults. - Removing the bookmarks example also removes one demonstration of plugin database namespaces plus local-folder persistence; remaining plugin examples still cover bundled example discovery and plugin host flows. - The plugin package itself is intentionally deferred to the stacked plugin-only PR, where LLM Wiki plugin-local spaces live. - Existing installs that tested the transient root-level spaces CLI should stop using it; this PR intentionally removes that unsupported migration surface before merge. > 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 GPT-5 Codex via Codex CLI, tool use and local code execution enabled; context window not exposed. ## 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, except where noted above for host-specific embedded Postgres initialization - [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 Stacked follow-up: PR #5592 contains only `packages/plugins/plugin-llm-wiki/` and targets this branch. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
614 lines
21 KiB
TypeScript
614 lines
21 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 async function deletePluginLocalFolderFile(
|
|
rootPath: string,
|
|
relativePath: string,
|
|
folderKey: string,
|
|
) {
|
|
const rootRealPath = await fs.realpath(rootPath);
|
|
let resolved: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
|
|
try {
|
|
resolved = await resolvePluginLocalFolderPath(rootRealPath, relativePath, {
|
|
mustExist: true,
|
|
allowMissingLeaf: true,
|
|
});
|
|
} catch (error) {
|
|
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
if (code !== "ENOENT") throw error;
|
|
return inspectPluginLocalFolder({
|
|
folderKey,
|
|
storedConfig: {
|
|
path: rootPath,
|
|
access: "readWrite",
|
|
},
|
|
});
|
|
}
|
|
|
|
if (resolved.exists) {
|
|
const stat = await fs.lstat(resolved.absolutePath);
|
|
if (stat.isDirectory()) {
|
|
throw badRequest("Local folder delete target must be a file");
|
|
}
|
|
await fs.rm(resolved.absolutePath, { force: true });
|
|
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,
|
|
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");
|
|
}
|
|
}
|