mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix: fail closed on unusable Codex auth during sandbox auth merge
When reusing a Codex sandbox, the workspace-restore step now performs an auth-merge check before adopting the sandbox's auth.json. The merge fails closed when: - host or sandbox Codex auth is unusable (missing token or account) - the subscription payload shapes differ between host and sandbox - subscription account identities do not match Additional hardening: node absence on PATH during the embedded decision script now exits non-zero instead of silently falling through. Co-change comments link parseAuth (the embedded script) to hasUsableAuthPayload (codex-home.ts) so a future auth-format change cannot silently diverge between the two implementations. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
98df214917
3422 files changed
+1614966
No files matched your search
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,23 @@
|
||||
# Plugin Authoring Smoke Example
|
||||
|
||||
A Paperclip plugin
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install ./
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
||||
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
|
||||
- `pnpm build:rollup` uses rollup presets from the same SDK.
|
||||
@@ -0,0 +1,17 @@
|
||||
import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-authoring-smoke-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "A Paperclip plugin",
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"dev": "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"connector"
|
||||
],
|
||||
"author": "Plugin Author",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^22.19.21",
|
||||
"@types/react": "^19.2.17",
|
||||
"esbuild": "^0.28.1",
|
||||
"rollup": "^4.62.2",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclipai.plugin-authoring-smoke-example",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Plugin Authoring Smoke Example",
|
||||
description: "A Paperclip plugin",
|
||||
author: "Plugin Author",
|
||||
categories: ["connector"],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"ui.dashboardWidget.register"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: "Plugin Authoring Smoke Example Health",
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export function DashboardWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<HealthData>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>Plugin Authoring Smoke Example</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
const issueId = event.entityId ?? "unknown";
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
|
||||
ctx.logger.info("Observed issue.created", { issueId });
|
||||
});
|
||||
|
||||
ctx.data.register("health", async () => {
|
||||
return { status: "ok", checkedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
ctx.actions.register("ping", async () => {
|
||||
ctx.logger.info("Ping action invoked");
|
||||
return { pong: true, at: new Date().toISOString() };
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Plugin worker is running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("declares capabilities for its manifest features", () => {
|
||||
expect(manifest.capabilities).toContain("events.subscribe");
|
||||
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
|
||||
});
|
||||
|
||||
it("registers data + actions and handles events", async () => {
|
||||
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
|
||||
|
||||
const data = await harness.getData<{ status: string }>("health");
|
||||
expect(data.status).toBe("ok");
|
||||
|
||||
const action = await harness.performAction<{ pong: boolean }>("ping");
|
||||
expect(action.pong).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# File Browser Example Plugin
|
||||
|
||||
Example Paperclip plugin that demonstrates:
|
||||
|
||||
- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off.
|
||||
- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support.
|
||||
|
||||
This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included.
|
||||
|
||||
## Slots
|
||||
|
||||
| Slot | Type | Description |
|
||||
|---------------------|---------------------|--------------------------------------------------|
|
||||
| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. |
|
||||
| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.|
|
||||
|
||||
## Settings
|
||||
|
||||
- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off.
|
||||
- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- `ui.sidebar.register` — project sidebar item
|
||||
- `ui.detailTab.register` — project detail tab
|
||||
- `projects.read` — resolve project
|
||||
- `project.workspaces.read` — list workspaces and read paths for file access
|
||||
|
||||
## Worker
|
||||
|
||||
- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first).
|
||||
- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`).
|
||||
- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`).
|
||||
- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk.
|
||||
|
||||
## Local Install (Dev)
|
||||
|
||||
From the repo root, build the plugin and install it by local path:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-file-browser-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin uninstall paperclip-file-browser-example --force
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
||||
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||
- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host.
|
||||
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin.
|
||||
- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`).
|
||||
- `src/worker.ts` — data handlers for workspaces, file list, file content.
|
||||
- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor).
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-file-browser-example",
|
||||
"version": "0.1.0",
|
||||
"description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"codemirror": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.21",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"esbuild": "^0.28.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip-file-browser-example";
|
||||
const FILES_SIDEBAR_SLOT_ID = "files-link";
|
||||
const FILES_TAB_SLOT_ID = "files-tab";
|
||||
const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links";
|
||||
const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.2.0",
|
||||
displayName: "File Browser (Example)",
|
||||
description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
"ui.sidebar.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issue.comments.read",
|
||||
"plugin.state.read",
|
||||
],
|
||||
instanceConfigSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
showFilesInSidebar: {
|
||||
type: "boolean",
|
||||
title: "Show Files in Sidebar",
|
||||
default: false,
|
||||
description: "Adds the Files link under each project in the sidebar.",
|
||||
},
|
||||
commentAnnotationMode: {
|
||||
type: "string",
|
||||
title: "Comment File Links",
|
||||
enum: ["annotation", "contextMenu", "both", "none"],
|
||||
default: "both",
|
||||
description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.",
|
||||
},
|
||||
},
|
||||
},
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "projectSidebarItem",
|
||||
id: FILES_SIDEBAR_SLOT_ID,
|
||||
displayName: "Files",
|
||||
exportName: "FilesLink",
|
||||
entityTypes: ["project"],
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: FILES_TAB_SLOT_ID,
|
||||
displayName: "Files",
|
||||
exportName: "FilesTab",
|
||||
entityTypes: ["project"],
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
type: "commentAnnotation",
|
||||
id: COMMENT_FILE_LINKS_SLOT_ID,
|
||||
displayName: "File Links",
|
||||
exportName: "CommentFileLinks",
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
{
|
||||
type: "commentContextMenuItem",
|
||||
id: COMMENT_OPEN_FILES_SLOT_ID,
|
||||
displayName: "Open in Files",
|
||||
exportName: "CommentOpenFiles",
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,773 @@
|
||||
import type {
|
||||
FileTreeNode,
|
||||
PluginProjectSidebarItemProps,
|
||||
PluginDetailTabProps,
|
||||
PluginCommentAnnotationProps,
|
||||
PluginCommentContextMenuItemProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import { FileTree, usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
import { useCallback, useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
const PLUGIN_KEY = "paperclip-file-browser-example";
|
||||
const FILES_TAB_SLOT_ID = "files-tab";
|
||||
|
||||
const editorBaseTheme = {
|
||||
"&": {
|
||||
height: "100%",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "12px 14px 18px",
|
||||
},
|
||||
};
|
||||
|
||||
const editorDarkTheme = EditorView.theme({
|
||||
...editorBaseTheme,
|
||||
"&": {
|
||||
...editorBaseTheme["&"],
|
||||
backgroundColor: "oklch(0.23 0.02 255)",
|
||||
color: "oklch(0.93 0.01 255)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "oklch(0.25 0.015 255)",
|
||||
color: "oklch(0.74 0.015 255)",
|
||||
borderRight: "1px solid oklch(0.34 0.01 255)",
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: "oklch(0.30 0.012 255 / 0.55)",
|
||||
},
|
||||
".cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "oklch(0.42 0.02 255 / 0.45)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "oklch(0.47 0.025 255 / 0.5)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "oklch(0.93 0.01 255)",
|
||||
},
|
||||
".cm-matchingBracket": {
|
||||
backgroundColor: "oklch(0.37 0.015 255 / 0.5)",
|
||||
color: "oklch(0.95 0.01 255)",
|
||||
outline: "none",
|
||||
},
|
||||
".cm-nonmatchingBracket": {
|
||||
color: "oklch(0.70 0.08 24)",
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
const editorLightTheme = EditorView.theme({
|
||||
...editorBaseTheme,
|
||||
"&": {
|
||||
...editorBaseTheme["&"],
|
||||
backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))",
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
".cm-content": {
|
||||
...editorBaseTheme[".cm-content"],
|
||||
caretColor: "var(--foreground)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))",
|
||||
color: "var(--muted-foreground)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)",
|
||||
},
|
||||
".cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)",
|
||||
},
|
||||
".cm-matchingBracket": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)",
|
||||
color: "var(--foreground)",
|
||||
outline: "none",
|
||||
},
|
||||
".cm-nonmatchingBracket": {
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
});
|
||||
|
||||
const editorDarkHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "oklch(0.78 0.025 265)" },
|
||||
{ tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" },
|
||||
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" },
|
||||
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" },
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" },
|
||||
{ tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" },
|
||||
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" },
|
||||
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" },
|
||||
]);
|
||||
|
||||
const editorLightHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "oklch(0.45 0.07 270)" },
|
||||
{ tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" },
|
||||
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" },
|
||||
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" },
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" },
|
||||
{ tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" },
|
||||
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" },
|
||||
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" },
|
||||
]);
|
||||
|
||||
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
|
||||
type FileEntry = { name: string; path: string; isDirectory: boolean };
|
||||
|
||||
function entryToFileTreeNode(entry: FileEntry): FileTreeNode {
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
kind: entry.isDirectory ? "dir" : "file",
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function entriesToFileTreeNodes(entries: FileEntry[]): FileTreeNode[] {
|
||||
return entries.map(entryToFileTreeNode);
|
||||
}
|
||||
|
||||
function setChildrenAtPath(nodes: FileTreeNode[], path: string, children: FileTreeNode[]): FileTreeNode[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.path === path) {
|
||||
return { ...node, children };
|
||||
}
|
||||
if (node.kind === "dir" && node.children.length > 0 && (path === node.path || path.startsWith(`${node.path}/`))) {
|
||||
return { ...node, children: setChildrenAtPath(node.children, path, children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
const PathLikePattern = /[\\/]/;
|
||||
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
|
||||
const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isLikelyPath(pathValue: string): boolean {
|
||||
const trimmed = pathValue.trim();
|
||||
return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed);
|
||||
}
|
||||
|
||||
function workspaceLabel(workspace: Workspace): string {
|
||||
const pathLabel = workspace.path.trim();
|
||||
const nameLabel = workspace.name.trim();
|
||||
const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel);
|
||||
const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel);
|
||||
const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : "";
|
||||
if (!baseLabel) {
|
||||
return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)";
|
||||
}
|
||||
|
||||
return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel;
|
||||
}
|
||||
|
||||
function useIsMobile(breakpointPx = 768): boolean {
|
||||
const [isMobile, setIsMobile] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth < breakpointPx : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`);
|
||||
const update = () => setIsMobile(mediaQuery.matches);
|
||||
update();
|
||||
mediaQuery.addEventListener("change", update);
|
||||
return () => mediaQuery.removeEventListener("change", update);
|
||||
}, [breakpointPx]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
function useIsDarkMode(): boolean {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() =>
|
||||
typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const update = () => setIsDarkMode(root.classList.contains("dark"));
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDarkMode;
|
||||
}
|
||||
|
||||
function useAvailableHeight(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
options?: { bottomPadding?: number; minHeight?: number },
|
||||
): number | null {
|
||||
const bottomPadding = options?.bottomPadding ?? 24;
|
||||
const minHeight = options?.minHeight ?? 384;
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const update = () => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding));
|
||||
setHeight(nextHeight);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
window.addEventListener("orientationchange", update);
|
||||
|
||||
const observer = typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => update())
|
||||
: null;
|
||||
if (observer && ref.current) observer.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("orientationchange", update);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [bottomPadding, minHeight, ref]);
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
|
||||
*/
|
||||
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||||
const { data: config, loading: configLoading } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const showFilesInSidebar = config?.showFilesInSidebar ?? false;
|
||||
|
||||
if (configLoading || !showFilesInSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectId = context.entityId;
|
||||
const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null })
|
||||
.projectRef
|
||||
?? projectId;
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
|
||||
const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`;
|
||||
const isActive = typeof window !== "undefined" && (() => {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, "");
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const projectsIndex = segments.indexOf("projects");
|
||||
const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null;
|
||||
const activeTab = new URLSearchParams(window.location.search).get("tab");
|
||||
if (activeTab !== tabValue) return false;
|
||||
if (!activeProjectRef) return false;
|
||||
return activeProjectRef === projectId || activeProjectRef === projectRef;
|
||||
})();
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.button !== 0
|
||||
|| event.metaKey
|
||||
|| event.ctrlKey
|
||||
|| event.altKey
|
||||
|| event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
window.history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`block px-3 py-1 text-[12px] truncate transition-colors ${
|
||||
isActive
|
||||
? "bg-accent text-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
Files
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project detail tab: workspace selector, file tree, and CodeMirror editor.
|
||||
*/
|
||||
export function FilesTab({ context }: PluginDetailTabProps) {
|
||||
const companyId = context.companyId;
|
||||
const projectId = context.entityId;
|
||||
const isMobile = useIsMobile();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const panesRef = useRef<HTMLDivElement | null>(null);
|
||||
const availableHeight = useAvailableHeight(panesRef, {
|
||||
bottomPadding: isMobile ? 16 : 24,
|
||||
minHeight: isMobile ? 320 : 420,
|
||||
});
|
||||
const { data: workspacesData } = usePluginData<Workspace[]>("workspaces", {
|
||||
projectId,
|
||||
companyId,
|
||||
});
|
||||
const workspaces = workspacesData ?? [];
|
||||
const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|");
|
||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||
const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null;
|
||||
const selectedWorkspace = useMemo(
|
||||
() => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null,
|
||||
[workspaces, resolvedWorkspaceId],
|
||||
);
|
||||
|
||||
const fileListParams = useMemo(
|
||||
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
|
||||
[companyId, projectId, selectedWorkspace],
|
||||
);
|
||||
const { data: fileListData, loading: fileListLoading, error: fileListError } = usePluginData<{ entries: FileEntry[] }>(
|
||||
"fileList",
|
||||
fileListParams,
|
||||
);
|
||||
|
||||
// Lazy-load directory children through an imperative action so the shared
|
||||
// FileTree can reuse `expandedPaths` for state without spawning a hook per
|
||||
// expanded directory.
|
||||
const loadFileList = usePluginAction("loadFileList");
|
||||
const [nodes, setNodes] = useState<FileTreeNode[]>([]);
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
|
||||
const [loadedDirs, setLoadedDirs] = useState<Set<string>>(() => new Set());
|
||||
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(fileListData?.entries ? entriesToFileTreeNodes(fileListData.entries) : []);
|
||||
setExpandedPaths(new Set());
|
||||
setLoadedDirs(new Set());
|
||||
setLoadingDirs(new Set());
|
||||
}, [fileListData, selectedWorkspace?.id]);
|
||||
|
||||
const handleToggleDir = useCallback(
|
||||
(dirPath: string) => {
|
||||
setExpandedPaths((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(dirPath)) next.delete(dirPath);
|
||||
else next.add(dirPath);
|
||||
return next;
|
||||
});
|
||||
if (!selectedWorkspace) return;
|
||||
if (loadedDirs.has(dirPath) || loadingDirs.has(dirPath)) return;
|
||||
setLoadingDirs((current) => new Set(current).add(dirPath));
|
||||
void loadFileList({
|
||||
projectId,
|
||||
companyId,
|
||||
workspaceId: selectedWorkspace.id,
|
||||
directoryPath: dirPath,
|
||||
})
|
||||
.then((response) => {
|
||||
const entries = (response as { entries?: FileEntry[] })?.entries ?? [];
|
||||
const children = entriesToFileTreeNodes(entries);
|
||||
setNodes((current) => setChildrenAtPath(current, dirPath, children));
|
||||
setLoadedDirs((current) => new Set(current).add(dirPath));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingDirs((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(dirPath);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[companyId, loadFileList, loadedDirs, loadingDirs, projectId, selectedWorkspace],
|
||||
);
|
||||
|
||||
// Track the `?file=` query parameter across navigations (popstate).
|
||||
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return new URLSearchParams(window.location.search).get("file") || null;
|
||||
});
|
||||
const lastConsumedFileRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onNav = () => {
|
||||
const next = new URLSearchParams(window.location.search).get("file") || null;
|
||||
setUrlFilePath(next);
|
||||
};
|
||||
window.addEventListener("popstate", onNav);
|
||||
return () => window.removeEventListener("popstate", onNav);
|
||||
}, []);
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setSelectedPath(null);
|
||||
setMobileView("browser");
|
||||
lastConsumedFileRef.current = null;
|
||||
}, [selectedWorkspace?.id]);
|
||||
|
||||
// When a file path appears (or changes) in the URL and workspace is ready, select it.
|
||||
useEffect(() => {
|
||||
if (!urlFilePath || !selectedWorkspace) return;
|
||||
if (lastConsumedFileRef.current === urlFilePath) return;
|
||||
lastConsumedFileRef.current = urlFilePath;
|
||||
setSelectedPath(urlFilePath);
|
||||
setMobileView("editor");
|
||||
}, [urlFilePath, selectedWorkspace]);
|
||||
|
||||
const fileContentParams = useMemo(
|
||||
() =>
|
||||
selectedPath && selectedWorkspace
|
||||
? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath }
|
||||
: null,
|
||||
[companyId, projectId, selectedWorkspace, selectedPath],
|
||||
);
|
||||
const fileContentResult = usePluginData<{ content: string | null; error?: string }>(
|
||||
"fileContent",
|
||||
fileContentParams ?? {},
|
||||
);
|
||||
const { data: fileContentData, refresh: refreshFileContent } = fileContentResult;
|
||||
const writeFile = usePluginAction("writeFile");
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const loadedContentRef = useRef("");
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [mobileView, setMobileView] = useState<"browser" | "editor">("browser");
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
const content = fileContentData?.content ?? "";
|
||||
loadedContentRef.current = content;
|
||||
setIsDirty(false);
|
||||
setSaveMessage(null);
|
||||
setSaveError(null);
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
const view = new EditorView({
|
||||
doc: content,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
javascript(),
|
||||
isDarkMode ? editorDarkTheme : editorLightTheme,
|
||||
syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged) return;
|
||||
const nextValue = update.state.doc.toString();
|
||||
setIsDirty(nextValue !== loadedContentRef.current);
|
||||
setSaveMessage(null);
|
||||
setSaveError(null);
|
||||
}),
|
||||
],
|
||||
parent: editorRef.current,
|
||||
});
|
||||
viewRef.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [fileContentData?.content, selectedPath, isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") {
|
||||
return;
|
||||
}
|
||||
if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [selectedWorkspace, selectedPath, isDirty, isSaving]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedWorkspace || !selectedPath || !viewRef.current) {
|
||||
return;
|
||||
}
|
||||
const content = viewRef.current.state.doc.toString();
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
setSaveMessage(null);
|
||||
try {
|
||||
await writeFile({
|
||||
projectId,
|
||||
companyId,
|
||||
workspaceId: selectedWorkspace.id,
|
||||
filePath: selectedPath,
|
||||
content,
|
||||
});
|
||||
loadedContentRef.current = content;
|
||||
setIsDirty(false);
|
||||
setSaveMessage("Saved");
|
||||
refreshFileContent();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<label className="text-sm font-medium text-muted-foreground">Workspace</label>
|
||||
<select
|
||||
key={workspaceSelectKey}
|
||||
className="mt-2 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={resolvedWorkspaceId ?? ""}
|
||||
onChange={(e) => setWorkspaceId(e.target.value || null)}
|
||||
>
|
||||
{workspaces.map((w) => {
|
||||
const label = workspaceLabel(w);
|
||||
return (
|
||||
<option key={`${w.id}:${label}`} value={w.id} label={label} title={label}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={panesRef}
|
||||
className="min-h-0"
|
||||
style={{
|
||||
display: isMobile ? "block" : "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: isMobile ? undefined : "320px minmax(0, 1fr)",
|
||||
height: availableHeight ? `${availableHeight}px` : undefined,
|
||||
minHeight: isMobile ? "20rem" : "26rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
style={{ display: isMobile && mobileView === "editor" ? "none" : "flex" }}
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
File Tree
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{selectedWorkspace ? (
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleDir={handleToggleDir}
|
||||
onSelectFile={(path: string) => {
|
||||
setSelectedPath(path);
|
||||
setMobileView("editor");
|
||||
}}
|
||||
loading={fileListLoading}
|
||||
error={fileListError ? { message: fileListError.message } : null}
|
||||
empty={{
|
||||
title: "No files",
|
||||
description: "No files found in this workspace.",
|
||||
}}
|
||||
ariaLabel="Workspace files"
|
||||
/>
|
||||
) : (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
style={{ display: isMobile && mobileView === "browser" ? "none" : "flex" }}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-card px-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 inline-flex rounded-md border border-input bg-background px-2 py-1 text-xs font-medium text-muted-foreground"
|
||||
style={{ display: isMobile ? "inline-flex" : "none" }}
|
||||
onClick={() => setMobileView("browser")}
|
||||
>
|
||||
Back to files
|
||||
</button>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Editor</div>
|
||||
<div className="truncate text-sm text-foreground">{selectedPath ?? "No file selected"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!selectedWorkspace || !selectedPath || !isDirty || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isDirty || saveMessage || saveError ? (
|
||||
<div className="border-b border-border px-4 py-2 text-xs">
|
||||
{saveError ? (
|
||||
<span className="text-destructive">{saveError}</span>
|
||||
) : saveMessage ? (
|
||||
<span className="text-emerald-600">{saveMessage}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? (
|
||||
<div className="border-b border-border px-4 py-2 text-xs text-destructive">{fileContentData.error}</div>
|
||||
) : null}
|
||||
<div ref={editorRef} className="min-h-0 flex-1 overflow-auto overscroll-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment Annotation: renders detected file links below each comment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PluginConfig = {
|
||||
showFilesInSidebar?: boolean;
|
||||
commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none";
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-comment annotation showing file-path-like links extracted from the
|
||||
* comment body. Each link navigates to the project Files tab with the
|
||||
* matching path pre-selected.
|
||||
*
|
||||
* Respects the `commentAnnotationMode` instance config — hidden when mode
|
||||
* is `"contextMenu"` or `"none"`.
|
||||
*/
|
||||
function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string {
|
||||
if (!projectId) return "#";
|
||||
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
|
||||
return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
function navigateToFileBrowser(href: string, event: MouseEvent<HTMLAnchorElement>) {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.button !== 0
|
||||
|| event.metaKey
|
||||
|| event.ctrlKey
|
||||
|| event.altKey
|
||||
|| event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
export function CommentFileLinks({ context }: PluginCommentAnnotationProps) {
|
||||
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const mode = config?.commentAnnotationMode ?? "both";
|
||||
|
||||
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
|
||||
commentId: context.entityId,
|
||||
issueId: context.parentEntityId,
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (mode === "contextMenu" || mode === "none") return null;
|
||||
if (!data?.links?.length) return null;
|
||||
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectId = context.projectId;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Files:</span>
|
||||
{data.links.map((link) => {
|
||||
const href = buildFileBrowserHref(prefix, projectId, link);
|
||||
return (
|
||||
<a
|
||||
key={link}
|
||||
href={href}
|
||||
onClick={(e) => navigateToFileBrowser(href, e)}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-1.5 py-0.5 text-xs font-mono text-primary hover:bg-accent/60 hover:underline transition-colors"
|
||||
title={`Open ${link} in file browser`}
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment Context Menu Item: "Open in Files" action per comment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-comment context menu item that appears in the comment "more" (⋮) menu.
|
||||
* Extracts file paths from the comment body and, if any are found, renders
|
||||
* a button to open the first file in the project Files tab.
|
||||
*
|
||||
* Respects the `commentAnnotationMode` instance config — hidden when mode
|
||||
* is `"annotation"` or `"none"`.
|
||||
*/
|
||||
export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) {
|
||||
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const mode = config?.commentAnnotationMode ?? "both";
|
||||
|
||||
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
|
||||
commentId: context.entityId,
|
||||
issueId: context.parentEntityId,
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (mode === "annotation" || mode === "none") return null;
|
||||
if (!data?.links?.length) return null;
|
||||
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectId = context.projectId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
{data.links.map((link) => {
|
||||
const href = buildFileBrowserHref(prefix, projectId, link);
|
||||
const fileName = link.split("/").pop() ?? link;
|
||||
return (
|
||||
<a
|
||||
key={link}
|
||||
href={href}
|
||||
onClick={(e) => navigateToFileBrowser(href, e)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs text-foreground hover:bg-accent transition-colors"
|
||||
title={`Open ${link} in file browser`}
|
||||
>
|
||||
<span className="truncate font-mono">{fileName}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const PLUGIN_NAME = "file-browser-example";
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const PATH_LIKE_PATTERN = /[\\/]/;
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
||||
|
||||
function looksLikePath(value: string): boolean {
|
||||
const normalized = value.trim();
|
||||
return (PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized))
|
||||
&& !UUID_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
function sanitizeWorkspacePath(pathValue: string): string {
|
||||
return looksLikePath(pathValue) ? pathValue.trim() : "";
|
||||
}
|
||||
|
||||
function resolveWorkspace(workspacePath: string, requestedPath?: string): string | null {
|
||||
const root = path.resolve(workspacePath);
|
||||
const resolved = requestedPath ? path.resolve(root, requestedPath) : root;
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex that matches file-path-like tokens in comment text.
|
||||
* Captures tokens that either start with `.` `/` `~` or contain a `/`
|
||||
* (directory separator), plus bare words that could be filenames with
|
||||
* extensions (e.g. `README.md`). The file-extension check in
|
||||
* `extractFilePaths` filters out non-file matches.
|
||||
*/
|
||||
const FILE_PATH_REGEX = /(?:^|[\s(`"'])([^\s,;)}`"'>\]]*\/[^\s,;)}`"'>\]]+|[.\/~][^\s,;)}`"'>\]]+|[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,10}(?:\/[^\s,;)}`"'>\]]+)?)/g;
|
||||
|
||||
/** Common file extensions to recognise path-like tokens as actual file references. */
|
||||
const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]{1,10}$/;
|
||||
|
||||
/**
|
||||
* Tokens that look like paths but are almost certainly URL route segments
|
||||
* (e.g. `/projects/abc`, `/settings`, `/dashboard`).
|
||||
*/
|
||||
const URL_ROUTE_PATTERN = /^\/(?:projects|issues|agents|settings|dashboard|plugins|api|auth|admin)\b/i;
|
||||
|
||||
function extractFilePaths(body: string): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const match of body.matchAll(FILE_PATH_REGEX)) {
|
||||
const raw = match[1];
|
||||
// Strip trailing punctuation that isn't part of a path
|
||||
const cleaned = raw.replace(/[.:,;!?)]+$/, "");
|
||||
if (cleaned.length <= 1) continue;
|
||||
// Must have a file extension (e.g. .ts, .json, .md)
|
||||
if (!FILE_EXTENSION_REGEX.test(cleaned)) continue;
|
||||
// Skip things that look like URL routes
|
||||
if (URL_ROUTE_PATTERN.test(cleaned)) continue;
|
||||
paths.add(cleaned);
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
|
||||
// Expose the current plugin config so UI components can read operator
|
||||
// settings from the canonical instance config store.
|
||||
ctx.data.register("plugin-config", async () => {
|
||||
const config = await ctx.config.get();
|
||||
return {
|
||||
showFilesInSidebar: config?.showFilesInSidebar === true,
|
||||
commentAnnotationMode: config?.commentAnnotationMode ?? "both",
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch a comment by ID and extract file-path-like tokens from its body.
|
||||
ctx.data.register("comment-file-links", async (params: Record<string, unknown>) => {
|
||||
const commentId = typeof params.commentId === "string" ? params.commentId : "";
|
||||
const issueId = typeof params.issueId === "string" ? params.issueId : "";
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
if (!commentId || !issueId || !companyId) return { links: [] };
|
||||
try {
|
||||
const comments = await ctx.issues.listComments(issueId, companyId);
|
||||
const comment = comments.find((c) => c.id === commentId);
|
||||
if (!comment?.body) return { links: [] };
|
||||
return { links: extractFilePaths(comment.body) };
|
||||
} catch (err) {
|
||||
ctx.logger.warn("Failed to fetch comment for file link extraction", { commentId, error: String(err) });
|
||||
return { links: [] };
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("workspaces", async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
if (!projectId || !companyId) return [];
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
return workspaces.map((w) => ({
|
||||
id: w.id,
|
||||
projectId: w.projectId,
|
||||
name: w.name,
|
||||
path: sanitizeWorkspacePath(w.path),
|
||||
isPrimary: w.isPrimary,
|
||||
}));
|
||||
});
|
||||
|
||||
async function readFileList(params: Record<string, unknown>) {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
|
||||
if (!projectId || !companyId || !workspaceId) return { entries: [] };
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) return { entries: [] };
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) return { entries: [] };
|
||||
const dirPath = resolveWorkspace(workspacePath, directoryPath);
|
||||
if (!dirPath) {
|
||||
return { entries: [] };
|
||||
}
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return { entries: [] };
|
||||
}
|
||||
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
|
||||
const entries = names.map((name) => {
|
||||
const full = path.join(dirPath, name);
|
||||
const stat = fs.lstatSync(full);
|
||||
const relativePath = path.relative(workspacePath, full);
|
||||
return {
|
||||
name,
|
||||
path: relativePath,
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return { entries };
|
||||
}
|
||||
|
||||
ctx.data.register("fileList", readFileList);
|
||||
|
||||
// Mirror `fileList` as an action so the UI can lazily fetch directory
|
||||
// children on tree expand without spawning a usePluginData hook per dir.
|
||||
ctx.actions.register("loadFileList", readFileList);
|
||||
|
||||
ctx.data.register(
|
||||
"fileContent",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const filePath = params.filePath as string;
|
||||
if (!projectId || !companyId || !workspaceId || !filePath) {
|
||||
return { content: null, error: "Missing file context" };
|
||||
}
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) return { content: null, error: "Workspace not found" };
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) return { content: null, error: "Workspace has no path" };
|
||||
const fullPath = resolveWorkspace(workspacePath, filePath);
|
||||
if (!fullPath) {
|
||||
return { content: null, error: "Path outside workspace" };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, "utf-8");
|
||||
return { content };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { content: null, error: message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.actions.register(
|
||||
"writeFile",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
|
||||
if (!filePath) {
|
||||
throw new Error("filePath must be a non-empty string");
|
||||
}
|
||||
const content = typeof params.content === "string" ? params.content : null;
|
||||
if (!projectId || !companyId || !workspaceId) {
|
||||
throw new Error("Missing workspace context");
|
||||
}
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) {
|
||||
throw new Error("Workspace has no path");
|
||||
}
|
||||
if (content === null) {
|
||||
throw new Error("Missing file content");
|
||||
}
|
||||
const fullPath = resolveWorkspace(workspacePath, filePath);
|
||||
if (!fullPath) {
|
||||
throw new Error("Path outside workspace");
|
||||
}
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Selected path is not a file");
|
||||
}
|
||||
fs.writeFileSync(fullPath, content, "utf-8");
|
||||
return {
|
||||
ok: true,
|
||||
path: filePath,
|
||||
bytes: Buffer.byteLength(content, "utf-8"),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: `${PLUGIN_NAME} ready` };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# @paperclipai/plugin-hello-world-example
|
||||
|
||||
First-party reference plugin showing the smallest possible UI extension.
|
||||
|
||||
## What It Demonstrates
|
||||
|
||||
- a manifest with a `dashboardWidget` UI slot
|
||||
- `entrypoints.ui` wiring for plugin UI bundles
|
||||
- a minimal React widget rendered in the Paperclip dashboard
|
||||
- reading host context (`companyId`) from `PluginWidgetProps`
|
||||
- worker lifecycle hooks (`setup`, `onHealth`) for basic runtime observability
|
||||
|
||||
## API Surface
|
||||
|
||||
- This example does not add custom HTTP endpoints.
|
||||
- The widget is discovered/rendered through host-managed plugin APIs (for example `GET /api/plugins/ui-contributions`).
|
||||
|
||||
## Notes
|
||||
|
||||
This is intentionally simple and is designed as the quickest "hello world" starting point for UI plugin authors.
|
||||
It is a repo-local example plugin for development, not a plugin that should be assumed to ship in generic production builds.
|
||||
|
||||
## Local Install (Dev)
|
||||
|
||||
From the repo root, build the plugin and install it by local path:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-hello-world-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
||||
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||
- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path.
|
||||
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin:
|
||||
`pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then
|
||||
`pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-hello-world-example",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party reference plugin that adds a Hello World dashboard widget",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.21",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
/**
|
||||
* Stable plugin ID used by host registration and namespacing.
|
||||
*/
|
||||
const PLUGIN_ID = "paperclip.hello-world-example";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
const DASHBOARD_WIDGET_SLOT_ID = "hello-world-dashboard-widget";
|
||||
const DASHBOARD_WIDGET_EXPORT_NAME = "HelloWorldDashboardWidget";
|
||||
|
||||
/**
|
||||
* Minimal manifest demonstrating a UI-only plugin with one dashboard widget slot.
|
||||
*/
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Hello World Widget (Example)",
|
||||
description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
|
||||
author: "Paperclip",
|
||||
categories: ["ui"],
|
||||
capabilities: ["ui.dashboardWidget.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: DASHBOARD_WIDGET_SLOT_ID,
|
||||
displayName: "Hello World",
|
||||
exportName: DASHBOARD_WIDGET_EXPORT_NAME,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
const WIDGET_LABEL = "Hello world plugin widget";
|
||||
|
||||
/**
|
||||
* Example dashboard widget showing the smallest possible UI contribution.
|
||||
*/
|
||||
export function HelloWorldDashboardWidget({ context }: PluginWidgetProps) {
|
||||
return (
|
||||
<section aria-label={WIDGET_LABEL}>
|
||||
<strong>Hello world</strong>
|
||||
<div>This widget was added by @paperclipai/plugin-hello-world-example.</div>
|
||||
{/* Include host context so authors can see where scoped IDs come from. */}
|
||||
<div>Company context: {context.companyId}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_NAME = "hello-world-example";
|
||||
const HEALTH_MESSAGE = "Hello World example plugin ready";
|
||||
|
||||
/**
|
||||
* Worker lifecycle hooks for the Hello World reference plugin.
|
||||
* This stays intentionally small so new authors can copy the shape quickly.
|
||||
*/
|
||||
const plugin = definePlugin({
|
||||
/**
|
||||
* Called when the host starts the plugin worker.
|
||||
*/
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup complete`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by the host health probe endpoint.
|
||||
*/
|
||||
async onHealth() {
|
||||
return { status: "ok", message: HEALTH_MESSAGE };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# @paperclipai/plugin-kitchen-sink-example
|
||||
|
||||
Kitchen Sink is the first-party reference plugin that demonstrates nearly the full currently implemented Paperclip plugin surface in one package.
|
||||
|
||||
It is intentionally broad:
|
||||
|
||||
- full plugin page
|
||||
- dashboard widget
|
||||
- project and issue surfaces
|
||||
- comment surfaces
|
||||
- sidebar surfaces
|
||||
- settings page
|
||||
- worker bridge data/actions
|
||||
- events, jobs, webhooks, tools, streams
|
||||
- state, entities, assets, metrics, activity
|
||||
- local workspace and process demos
|
||||
|
||||
This plugin is for local development, contributor onboarding, and runtime regression testing. It is not meant as a production plugin template to ship unchanged.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/plugin-kitchen-sink-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-kitchen-sink-example
|
||||
```
|
||||
|
||||
Or install it from the Paperclip plugin manager as a bundled example once this repo is built.
|
||||
|
||||
## Notes
|
||||
|
||||
- Local workspace and process demos are trusted-only and default to safe, curated commands.
|
||||
- The plugin settings page lets you toggle optional demo surfaces and local runtime behavior.
|
||||
- Some SDK-defined host surfaces still depend on the Paperclip host wiring them visibly; this package aims to exercise the currently mounted ones and make the rest obvious.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-kitchen-sink-example",
|
||||
"version": "0.1.0",
|
||||
"description": "Reference plugin that demonstrates the full Paperclip plugin surface area in one package",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.1",
|
||||
"@types/node": "^22.19.21",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PluginLauncherRegistration } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const PLUGIN_ID = "paperclip-kitchen-sink-example";
|
||||
export const PLUGIN_VERSION = "0.1.0";
|
||||
export const PAGE_ROUTE = "kitchensink";
|
||||
|
||||
export const SLOT_IDS = {
|
||||
page: "kitchen-sink-page",
|
||||
settingsPage: "kitchen-sink-settings-page",
|
||||
companySettingsPage: "kitchen-sink-company-settings-page",
|
||||
dashboardWidget: "kitchen-sink-dashboard-widget",
|
||||
sidebar: "kitchen-sink-sidebar-link",
|
||||
sidebarPanel: "kitchen-sink-sidebar-panel",
|
||||
projectSidebarItem: "kitchen-sink-project-link",
|
||||
projectTab: "kitchen-sink-project-tab",
|
||||
issueTab: "kitchen-sink-issue-tab",
|
||||
taskDetailView: "kitchen-sink-task-detail",
|
||||
toolbarButton: "kitchen-sink-toolbar-action",
|
||||
contextMenuItem: "kitchen-sink-context-action",
|
||||
commentAnnotation: "kitchen-sink-comment-annotation",
|
||||
commentContextMenuItem: "kitchen-sink-comment-action",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_NAMES = {
|
||||
page: "KitchenSinkPage",
|
||||
settingsPage: "KitchenSinkSettingsPage",
|
||||
companySettingsPage: "KitchenSinkCompanySettingsPage",
|
||||
dashboardWidget: "KitchenSinkDashboardWidget",
|
||||
sidebar: "KitchenSinkSidebarLink",
|
||||
sidebarPanel: "KitchenSinkSidebarPanel",
|
||||
projectSidebarItem: "KitchenSinkProjectSidebarItem",
|
||||
projectTab: "KitchenSinkProjectTab",
|
||||
issueTab: "KitchenSinkIssueTab",
|
||||
taskDetailView: "KitchenSinkTaskDetailView",
|
||||
toolbarButton: "KitchenSinkToolbarButton",
|
||||
contextMenuItem: "KitchenSinkContextMenuItem",
|
||||
commentAnnotation: "KitchenSinkCommentAnnotation",
|
||||
commentContextMenuItem: "KitchenSinkCommentContextMenuItem",
|
||||
launcherModal: "KitchenSinkLauncherModal",
|
||||
} as const;
|
||||
|
||||
export const JOB_KEYS = {
|
||||
heartbeat: "demo-heartbeat",
|
||||
} as const;
|
||||
|
||||
export const WEBHOOK_KEYS = {
|
||||
demo: "demo-ingest",
|
||||
} as const;
|
||||
|
||||
export const TOOL_NAMES = {
|
||||
echo: "echo",
|
||||
companySummary: "company-summary",
|
||||
createIssue: "create-issue",
|
||||
} as const;
|
||||
|
||||
export const STREAM_CHANNELS = {
|
||||
progress: "progress",
|
||||
agentChat: "agent-chat",
|
||||
} as const;
|
||||
|
||||
export const SAFE_COMMANDS = [
|
||||
{
|
||||
key: "pwd",
|
||||
label: "Print workspace path",
|
||||
command: "pwd",
|
||||
args: [] as string[],
|
||||
description: "Prints the current workspace directory.",
|
||||
},
|
||||
{
|
||||
key: "ls",
|
||||
label: "List workspace files",
|
||||
command: "ls",
|
||||
args: ["-la"] as string[],
|
||||
description: "Lists files in the selected workspace.",
|
||||
},
|
||||
{
|
||||
key: "git-status",
|
||||
label: "Git status",
|
||||
command: "git",
|
||||
args: ["status", "--short", "--branch"] as string[],
|
||||
description: "Shows git status for the selected workspace.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type SafeCommandKey = (typeof SAFE_COMMANDS)[number]["key"];
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
showSidebarEntry: true,
|
||||
showSidebarPanel: true,
|
||||
showProjectSidebarItem: true,
|
||||
showCommentAnnotation: true,
|
||||
showCommentContextMenuItem: true,
|
||||
enableWorkspaceDemos: true,
|
||||
enableProcessDemos: false,
|
||||
secretRefExample: "",
|
||||
httpDemoUrl: "https://httpbin.org/anything",
|
||||
allowedCommands: SAFE_COMMANDS.map((command) => command.key),
|
||||
workspaceScratchFile: ".paperclip-kitchen-sink-demo.txt",
|
||||
} as const;
|
||||
|
||||
export const RUNTIME_LAUNCHER: PluginLauncherRegistration = {
|
||||
id: "kitchen-sink-runtime-launcher",
|
||||
displayName: "Kitchen Sink Modal",
|
||||
description: "Demonstrates runtime launcher registration from the worker.",
|
||||
placementZone: "toolbarButton",
|
||||
entityTypes: ["project", "issue"],
|
||||
action: {
|
||||
type: "openModal",
|
||||
target: EXPORT_NAMES.launcherModal,
|
||||
},
|
||||
render: {
|
||||
environment: "hostOverlay",
|
||||
bounds: "wide",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
EXPORT_NAMES,
|
||||
JOB_KEYS,
|
||||
PAGE_ROUTE,
|
||||
PLUGIN_ID,
|
||||
PLUGIN_VERSION,
|
||||
SLOT_IDS,
|
||||
TOOL_NAMES,
|
||||
WEBHOOK_KEYS,
|
||||
} from "./constants.js";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Kitchen Sink (Example)",
|
||||
description: "Reference plugin that demonstrates the current Paperclip plugin API surface, UI surfaces, bridge actions, events, jobs, webhooks, tools, local workspace access, and runtime diagnostics in one place.",
|
||||
author: "Paperclip",
|
||||
categories: ["ui", "automation", "workspace", "connector"],
|
||||
capabilities: [
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.read",
|
||||
"issue.comments.create",
|
||||
"agents.read",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agent.sessions.close",
|
||||
"goals.read",
|
||||
"goals.create",
|
||||
"goals.update",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
"telemetry.track",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"events.subscribe",
|
||||
"events.emit",
|
||||
"jobs.schedule",
|
||||
"webhooks.receive",
|
||||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
"agent.tools.register",
|
||||
"instance.settings.register",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
instanceConfigSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
showSidebarEntry: {
|
||||
type: "boolean",
|
||||
title: "Show Sidebar Entry",
|
||||
default: DEFAULT_CONFIG.showSidebarEntry,
|
||||
},
|
||||
showSidebarPanel: {
|
||||
type: "boolean",
|
||||
title: "Show Sidebar Panel",
|
||||
default: DEFAULT_CONFIG.showSidebarPanel,
|
||||
},
|
||||
showProjectSidebarItem: {
|
||||
type: "boolean",
|
||||
title: "Show Project Sidebar Item",
|
||||
default: DEFAULT_CONFIG.showProjectSidebarItem,
|
||||
},
|
||||
showCommentAnnotation: {
|
||||
type: "boolean",
|
||||
title: "Show Comment Annotation",
|
||||
default: DEFAULT_CONFIG.showCommentAnnotation,
|
||||
},
|
||||
showCommentContextMenuItem: {
|
||||
type: "boolean",
|
||||
title: "Show Comment Action",
|
||||
default: DEFAULT_CONFIG.showCommentContextMenuItem,
|
||||
},
|
||||
enableWorkspaceDemos: {
|
||||
type: "boolean",
|
||||
title: "Enable Workspace Demos",
|
||||
default: DEFAULT_CONFIG.enableWorkspaceDemos,
|
||||
},
|
||||
enableProcessDemos: {
|
||||
type: "boolean",
|
||||
title: "Enable Process Demos",
|
||||
default: DEFAULT_CONFIG.enableProcessDemos,
|
||||
description: "Allows curated local child-process demos in project workspaces.",
|
||||
},
|
||||
secretRefExample: {
|
||||
type: "string",
|
||||
title: "Secret Reference Example",
|
||||
default: DEFAULT_CONFIG.secretRefExample,
|
||||
},
|
||||
httpDemoUrl: {
|
||||
type: "string",
|
||||
title: "HTTP Demo URL",
|
||||
default: DEFAULT_CONFIG.httpDemoUrl,
|
||||
},
|
||||
allowedCommands: {
|
||||
type: "array",
|
||||
title: "Allowed Process Commands",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: DEFAULT_CONFIG.allowedCommands,
|
||||
},
|
||||
default: DEFAULT_CONFIG.allowedCommands,
|
||||
},
|
||||
workspaceScratchFile: {
|
||||
type: "string",
|
||||
title: "Workspace Scratch File",
|
||||
default: DEFAULT_CONFIG.workspaceScratchFile,
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs: [
|
||||
{
|
||||
jobKey: JOB_KEYS.heartbeat,
|
||||
displayName: "Demo Heartbeat",
|
||||
description: "Periodic demo job that records plugin runtime activity.",
|
||||
schedule: "*/15 * * * *",
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
endpointKey: WEBHOOK_KEYS.demo,
|
||||
displayName: "Demo Ingest",
|
||||
description: "Accepts arbitrary webhook payloads and records the latest delivery in plugin state.",
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: TOOL_NAMES.echo,
|
||||
displayName: "Kitchen Sink Echo",
|
||||
description: "Returns the provided message and the current run context.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.companySummary,
|
||||
displayName: "Kitchen Sink Company Summary",
|
||||
description: "Summarizes the current company using the Paperclip domain APIs.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.createIssue,
|
||||
displayName: "Kitchen Sink Create Issue",
|
||||
description: "Creates an issue in the current project from an agent tool call.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: SLOT_IDS.page,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.page,
|
||||
routePath: PAGE_ROUTE,
|
||||
},
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: SLOT_IDS.settingsPage,
|
||||
displayName: "Kitchen Sink Settings",
|
||||
exportName: EXPORT_NAMES.settingsPage,
|
||||
},
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: SLOT_IDS.companySettingsPage,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.companySettingsPage,
|
||||
routePath: "kitchen-sink",
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: SLOT_IDS.dashboardWidget,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.dashboardWidget,
|
||||
},
|
||||
{
|
||||
type: "sidebar",
|
||||
id: SLOT_IDS.sidebar,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.sidebar,
|
||||
},
|
||||
{
|
||||
type: "sidebarPanel",
|
||||
id: SLOT_IDS.sidebarPanel,
|
||||
displayName: "Kitchen Sink Panel",
|
||||
exportName: EXPORT_NAMES.sidebarPanel,
|
||||
},
|
||||
{
|
||||
type: "projectSidebarItem",
|
||||
id: SLOT_IDS.projectSidebarItem,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.projectSidebarItem,
|
||||
entityTypes: ["project"],
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: SLOT_IDS.projectTab,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.projectTab,
|
||||
entityTypes: ["project"],
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: SLOT_IDS.issueTab,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.issueTab,
|
||||
entityTypes: ["issue"],
|
||||
},
|
||||
{
|
||||
type: "taskDetailView",
|
||||
id: SLOT_IDS.taskDetailView,
|
||||
displayName: "Kitchen Sink Task View",
|
||||
exportName: EXPORT_NAMES.taskDetailView,
|
||||
entityTypes: ["issue"],
|
||||
},
|
||||
{
|
||||
type: "toolbarButton",
|
||||
id: SLOT_IDS.toolbarButton,
|
||||
displayName: "Kitchen Sink Action",
|
||||
exportName: EXPORT_NAMES.toolbarButton,
|
||||
entityTypes: ["project", "issue"],
|
||||
},
|
||||
{
|
||||
type: "contextMenuItem",
|
||||
id: SLOT_IDS.contextMenuItem,
|
||||
displayName: "Kitchen Sink Context",
|
||||
exportName: EXPORT_NAMES.contextMenuItem,
|
||||
entityTypes: ["project", "issue"],
|
||||
},
|
||||
{
|
||||
type: "commentAnnotation",
|
||||
id: SLOT_IDS.commentAnnotation,
|
||||
displayName: "Kitchen Sink Comment Annotation",
|
||||
exportName: EXPORT_NAMES.commentAnnotation,
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
{
|
||||
type: "commentContextMenuItem",
|
||||
id: SLOT_IDS.commentContextMenuItem,
|
||||
displayName: "Kitchen Sink Comment Action",
|
||||
exportName: EXPORT_NAMES.commentContextMenuItem,
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
],
|
||||
launchers: [
|
||||
{
|
||||
id: "kitchen-sink-launcher",
|
||||
displayName: "Kitchen Sink Modal",
|
||||
placementZone: "toolbarButton",
|
||||
entityTypes: ["project", "issue"],
|
||||
action: {
|
||||
type: "openModal",
|
||||
target: EXPORT_NAMES.launcherModal,
|
||||
},
|
||||
render: {
|
||||
environment: "hostOverlay",
|
||||
bounds: "wide",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const;
|
||||
const TARGET_FPS = 24;
|
||||
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
|
||||
|
||||
const PAPERCLIP_SPRITES = [
|
||||
[
|
||||
" ╭────╮ ",
|
||||
" ╭╯╭──╮│ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ ╰──╯│ ",
|
||||
" ╰─────╯ ",
|
||||
],
|
||||
[
|
||||
" ╭─────╮ ",
|
||||
" │╭──╮╰╮ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" │╰──╯ │ ",
|
||||
" ╰────╯ ",
|
||||
],
|
||||
] as const;
|
||||
|
||||
type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number];
|
||||
|
||||
interface Clip {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
drift: number;
|
||||
sprite: PaperclipSprite;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureChar(container: HTMLElement): { w: number; h: number } {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "M";
|
||||
span.style.cssText =
|
||||
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
|
||||
container.appendChild(span);
|
||||
const rect = span.getBoundingClientRect();
|
||||
container.removeChild(span);
|
||||
return { w: rect.width, h: rect.height };
|
||||
}
|
||||
|
||||
function spriteSize(sprite: PaperclipSprite): { width: number; height: number } {
|
||||
let width = 0;
|
||||
for (const row of sprite) width = Math.max(width, row.length);
|
||||
return { width, height: sprite.length };
|
||||
}
|
||||
|
||||
export function AsciiArtAnimation() {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preRef.current) return;
|
||||
const preEl: HTMLPreElement = preRef.current;
|
||||
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let isVisible = document.visibilityState !== "hidden";
|
||||
let loopActive = false;
|
||||
let lastRenderAt = 0;
|
||||
let tick = 0;
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let charW = 7;
|
||||
let charH = 11;
|
||||
let trail = new Float32Array(0);
|
||||
let colWave = new Float32Array(0);
|
||||
let rowWave = new Float32Array(0);
|
||||
let clipMask = new Uint16Array(0);
|
||||
let clips: Clip[] = [];
|
||||
let lastOutput = "";
|
||||
|
||||
function toGlyph(value: number): string {
|
||||
const clamped = Math.max(0, Math.min(0.999, value));
|
||||
const idx = Math.floor(clamped * CHARS.length);
|
||||
return CHARS[idx] ?? " ";
|
||||
}
|
||||
|
||||
function rebuildGrid() {
|
||||
const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
|
||||
const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
|
||||
if (nextCols === cols && nextRows === rows) return;
|
||||
|
||||
cols = nextCols;
|
||||
rows = nextRows;
|
||||
const cellCount = cols * rows;
|
||||
trail = new Float32Array(cellCount);
|
||||
colWave = new Float32Array(cols);
|
||||
rowWave = new Float32Array(rows);
|
||||
clipMask = new Uint16Array(cellCount);
|
||||
clips = clips.filter((clip) => {
|
||||
return (
|
||||
clip.x > -clip.width - 2 &&
|
||||
clip.x < cols + 2 &&
|
||||
clip.y > -clip.height - 2 &&
|
||||
clip.y < rows + 2
|
||||
);
|
||||
});
|
||||
lastOutput = "";
|
||||
}
|
||||
|
||||
function drawStaticFrame() {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
preEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22;
|
||||
grid[r]![c] = toGlyph(ambient);
|
||||
}
|
||||
}
|
||||
|
||||
const gapX = 18;
|
||||
const gapY = 13;
|
||||
for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) {
|
||||
const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10;
|
||||
for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) {
|
||||
const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!;
|
||||
for (let sr = 0; sr < sprite.length; sr++) {
|
||||
const line = sprite[sr]!;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const row = baseRow + sr;
|
||||
const col = baseCol + sc;
|
||||
if (row < 0 || row >= rows || col < 0 || col >= cols) continue;
|
||||
grid[row]![col] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = grid.map((line) => line.join("")).join("\n");
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
|
||||
function spawnClip() {
|
||||
const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!;
|
||||
const size = spriteSize(sprite);
|
||||
const edge = Math.random();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
if (edge < 0.68) {
|
||||
x = Math.random() < 0.5 ? -size.width - 1 : cols + 1;
|
||||
y = Math.random() * Math.max(1, rows - size.height);
|
||||
vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05);
|
||||
vy = (Math.random() - 0.5) * 0.014;
|
||||
} else {
|
||||
x = Math.random() * Math.max(1, cols - size.width);
|
||||
y = Math.random() < 0.5 ? -size.height - 1 : rows + 1;
|
||||
vx = (Math.random() - 0.5) * 0.014;
|
||||
vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034);
|
||||
}
|
||||
|
||||
clips.push({
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
life: 0,
|
||||
maxLife: 260 + Math.random() * 220,
|
||||
drift: (Math.random() - 0.5) * 1.2,
|
||||
sprite,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
}
|
||||
|
||||
function stampClip(clip: Clip, alpha: number) {
|
||||
const baseCol = Math.round(clip.x);
|
||||
const baseRow = Math.round(clip.y);
|
||||
for (let sr = 0; sr < clip.sprite.length; sr++) {
|
||||
const line = clip.sprite[sr]!;
|
||||
const row = baseRow + sr;
|
||||
if (row < 0 || row >= rows) continue;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const col = baseCol + sc;
|
||||
if (col < 0 || col >= cols) continue;
|
||||
const idx = row * cols + col;
|
||||
const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
|
||||
trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
|
||||
clipMask[idx] = ch.charCodeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function step(time: number) {
|
||||
if (!loopActive) return;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
|
||||
|
||||
const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
|
||||
lastRenderAt = time;
|
||||
tick += delta;
|
||||
|
||||
const cellCount = cols * rows;
|
||||
const targetCount = Math.max(3, Math.floor(cellCount / 2200));
|
||||
while (clips.length < targetCount) spawnClip();
|
||||
|
||||
for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
|
||||
clipMask.fill(0);
|
||||
|
||||
for (let i = clips.length - 1; i >= 0; i--) {
|
||||
const clip = clips[i]!;
|
||||
clip.life += delta;
|
||||
|
||||
const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
|
||||
const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
|
||||
clip.vx = (clip.vx + wobbleX) * 0.998;
|
||||
clip.vy = (clip.vy + wobbleY) * 0.998;
|
||||
|
||||
clip.x += clip.vx * delta;
|
||||
clip.y += clip.vy * delta;
|
||||
|
||||
if (
|
||||
clip.life >= clip.maxLife ||
|
||||
clip.x < -clip.width - 2 ||
|
||||
clip.x > cols + 2 ||
|
||||
clip.y < -clip.height - 2 ||
|
||||
clip.y > rows + 2
|
||||
) {
|
||||
clips.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const life = clip.life / clip.maxLife;
|
||||
const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
|
||||
stampClip(clip, alpha);
|
||||
}
|
||||
|
||||
for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06);
|
||||
for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05);
|
||||
|
||||
let output = "";
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c;
|
||||
const clipChar = clipMask[idx];
|
||||
if (clipChar > 0) {
|
||||
output += String.fromCharCode(clipChar);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ambient = 0.2 + colWave[c]! * 0.08 + rowWave[r]! * 0.06 + Math.sin((c + r) * 0.1 + tick * 0.035) * 0.05;
|
||||
output += toGlyph((trail[idx] ?? 0) + ambient);
|
||||
}
|
||||
if (r < rows - 1) output += "\n";
|
||||
}
|
||||
|
||||
if (output !== lastOutput) {
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const measured = measureChar(preEl);
|
||||
charW = measured.w || 7;
|
||||
charH = measured.h || 11;
|
||||
rebuildGrid();
|
||||
if (motionMedia.matches || !isVisible) {
|
||||
drawStaticFrame();
|
||||
}
|
||||
});
|
||||
|
||||
function startLoop() {
|
||||
if (loopActive) return;
|
||||
loopActive = true;
|
||||
lastRenderAt = 0;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function stopLoop() {
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMode() {
|
||||
if (motionMedia.matches || !isVisible) {
|
||||
stopLoop();
|
||||
drawStaticFrame();
|
||||
} else {
|
||||
startLoop();
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
isVisible = document.visibilityState !== "hidden";
|
||||
syncMode();
|
||||
}
|
||||
|
||||
const measured = measureChar(preEl);
|
||||
charW = measured.w || 7;
|
||||
charH = measured.h || 11;
|
||||
rebuildGrid();
|
||||
resizeObserver.observe(preEl);
|
||||
motionMedia.addEventListener("change", syncMode);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
syncMode();
|
||||
|
||||
return () => {
|
||||
stopLoop();
|
||||
resizeObserver.disconnect();
|
||||
motionMedia.removeEventListener("change", syncMode);
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "320px",
|
||||
minHeight: "320px",
|
||||
maxHeight: "350px",
|
||||
background: "#1d1d1d",
|
||||
color: "#f2efe6",
|
||||
overflow: "hidden",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid color-mix(in srgb, var(--border) 75%, transparent)",
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
ref={preRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "14px",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
whiteSpace: "pre",
|
||||
userSelect: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
.paperclip-sdk
|
||||
@@ -0,0 +1,48 @@
|
||||
# Plugin Orchestration Smoke Example
|
||||
|
||||
This first-party example validates the orchestration-grade plugin host surface.
|
||||
It is intentionally small and exists as an acceptance fixture rather than a
|
||||
product plugin.
|
||||
|
||||
## What it exercises
|
||||
|
||||
- `apiRoutes` under `/api/plugins/:pluginId/api/*`
|
||||
- restricted database migrations and runtime `ctx.db`
|
||||
- plugin-owned rows joined to `public.issues`
|
||||
- plugin-created child issues with namespaced origin metadata
|
||||
- billing codes, workspace inheritance, blocker relations, documents, wakeups,
|
||||
and orchestration summaries
|
||||
- issue detail and settings UI slots that surface route, capability, namespace,
|
||||
and smoke status
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
Use an absolute local path during development:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/absolute/path/to/paperclip/packages/plugins/examples/plugin-orchestration-smoke-example","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Scoped Route Smoke
|
||||
|
||||
After the plugin is ready, run the scoped route against an existing issue:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/paperclipai.plugin-orchestration-smoke-example/api/issues/<issue-id>/smoke \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"assigneeAgentId":"<agent-id>"}'
|
||||
```
|
||||
|
||||
The route returns the generated child issue, resolved blocker, billing code,
|
||||
subtree ids, and wakeup result.
|
||||
@@ -0,0 +1,17 @@
|
||||
import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE plugin_orchestration_smoke_1e8c264c64.smoke_runs (
|
||||
id uuid PRIMARY KEY,
|
||||
root_issue_id uuid NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE,
|
||||
child_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
blocker_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
billing_code text NOT NULL,
|
||||
last_summary jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-orchestration-smoke-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "First-party smoke plugin for orchestration-grade Paperclip plugin APIs",
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"dev": "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"connector"
|
||||
],
|
||||
"author": "Paperclip",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^22.19.21",
|
||||
"@types/react": "^19.2.17",
|
||||
"esbuild": "^0.28.1",
|
||||
"rollup": "^4.62.2",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclipai.plugin-orchestration-smoke-example",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Plugin Orchestration Smoke Example",
|
||||
description: "First-party smoke plugin that exercises Paperclip orchestration-grade plugin APIs.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation", "ui"],
|
||||
capabilities: [
|
||||
"api.routes.register",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.wakeup",
|
||||
"issue.relations.read",
|
||||
"issue.relations.write",
|
||||
"issue.documents.read",
|
||||
"issue.documents.write",
|
||||
"issue.subtree.read",
|
||||
"issues.orchestration.read",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.detailTab.register",
|
||||
"instance.settings.register"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
database: {
|
||||
namespaceSlug: "orchestration_smoke",
|
||||
migrationsDir: "migrations",
|
||||
coreReadTables: ["issues"]
|
||||
},
|
||||
apiRoutes: [
|
||||
{
|
||||
routeKey: "initialize",
|
||||
method: "POST",
|
||||
path: "/issues/:issueId/smoke",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
checkoutPolicy: "required-for-agent-in-progress",
|
||||
companyResolution: { from: "issue", param: "issueId" }
|
||||
},
|
||||
{
|
||||
routeKey: "summary",
|
||||
method: "GET",
|
||||
path: "/issues/:issueId/smoke",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "issue", param: "issueId" }
|
||||
}
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: "Orchestration Smoke Health",
|
||||
exportName: "DashboardWidget"
|
||||
},
|
||||
{
|
||||
type: "taskDetailView",
|
||||
id: "issue-panel",
|
||||
displayName: "Orchestration Smoke",
|
||||
exportName: "IssuePanel",
|
||||
entityTypes: ["issue"]
|
||||
},
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: "settings",
|
||||
displayName: "Orchestration Smoke",
|
||||
exportName: "SettingsPage"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
usePluginAction,
|
||||
usePluginData,
|
||||
type PluginDetailTabProps,
|
||||
type PluginSettingsPageProps,
|
||||
type PluginWidgetProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import type React from "react";
|
||||
|
||||
type SurfaceStatus = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
checkedAt: string;
|
||||
databaseNamespace: string;
|
||||
routeKeys: string[];
|
||||
capabilities: string[];
|
||||
summary: null | {
|
||||
rootIssueId: string;
|
||||
childIssueId: string | null;
|
||||
blockerIssueId: string | null;
|
||||
billingCode: string;
|
||||
subtreeIssueIds: string[];
|
||||
wakeupQueued: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const panelStyle = {
|
||||
display: "grid",
|
||||
gap: 10,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.45,
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const rowStyle = {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const buttonStyle = {
|
||||
border: "1px solid #1f2937",
|
||||
background: "#111827",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
padding: "6px 10px",
|
||||
font: "inherit",
|
||||
cursor: "pointer",
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
function SurfaceRows({ data }: { data: SurfaceStatus }) {
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<div style={rowStyle}><span>Status</span><strong>{data.status}</strong></div>
|
||||
<div style={rowStyle}><span>Namespace</span><code>{data.databaseNamespace}</code></div>
|
||||
<div style={rowStyle}><span>Routes</span><code>{data.routeKeys.join(", ")}</code></div>
|
||||
<div style={rowStyle}><span>Capabilities</span><strong>{data.capabilities.length}</strong></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<SurfaceStatus>("surface-status", {
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading orchestration smoke status...</div>;
|
||||
if (error) return <div>Orchestration smoke error: {error.message}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<strong>Orchestration Smoke</strong>
|
||||
<SurfaceRows data={data} />
|
||||
<div>Checked {data.checkedAt}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssuePanel({ context }: PluginDetailTabProps) {
|
||||
const { data, loading, error, refresh } = usePluginData<SurfaceStatus>("surface-status", {
|
||||
companyId: context.companyId,
|
||||
issueId: context.entityId,
|
||||
});
|
||||
const initialize = usePluginAction("initialize-smoke");
|
||||
|
||||
if (loading) return <div>Loading orchestration smoke...</div>;
|
||||
if (error) return <div>Orchestration smoke error: {error.message}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<div style={rowStyle}>
|
||||
<strong>Orchestration Smoke</strong>
|
||||
<button
|
||||
style={buttonStyle}
|
||||
onClick={async () => {
|
||||
await initialize({ companyId: context.companyId, issueId: context.entityId });
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
Run Smoke
|
||||
</button>
|
||||
</div>
|
||||
<SurfaceRows data={data} />
|
||||
{data.summary ? (
|
||||
<div style={{ display: "grid", gap: 4 }}>
|
||||
<div style={rowStyle}><span>Child</span><code>{data.summary.childIssueId ?? "none"}</code></div>
|
||||
<div style={rowStyle}><span>Blocker</span><code>{data.summary.blockerIssueId ?? "none"}</code></div>
|
||||
<div style={rowStyle}><span>Billing</span><code>{data.summary.billingCode}</code></div>
|
||||
<div style={rowStyle}><span>Subtree</span><strong>{data.summary.subtreeIssueIds.length}</strong></div>
|
||||
<div style={rowStyle}><span>Wakeup</span><strong>{data.summary.wakeupQueued ? "queued" : "not queued"}</strong></div>
|
||||
</div>
|
||||
) : (
|
||||
<div>No smoke run recorded for this issue.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage({ context }: PluginSettingsPageProps) {
|
||||
const { data, loading, error } = usePluginData<SurfaceStatus>("surface-status", {
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading orchestration smoke settings...</div>;
|
||||
if (error) return <div>Orchestration smoke settings error: {error.message}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<strong>Orchestration Smoke Surface</strong>
|
||||
<SurfaceRows data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { definePlugin, runWorker, type PluginApiRequestInput } from "@paperclipai/plugin-sdk";
|
||||
|
||||
type SmokeInput = {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
assigneeAgentId?: string | null;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
|
||||
type SmokeSummary = {
|
||||
rootIssueId: string;
|
||||
childIssueId: string | null;
|
||||
blockerIssueId: string | null;
|
||||
billingCode: string;
|
||||
joinedRows: unknown[];
|
||||
subtreeIssueIds: string[];
|
||||
wakeupQueued: boolean;
|
||||
};
|
||||
|
||||
let readSmokeSummary: ((companyId: string, issueId: string) => Promise<SmokeSummary | null>) | null = null;
|
||||
let initializeSmoke: ((input: SmokeInput) => Promise<SmokeSummary>) | null = null;
|
||||
|
||||
function tableName(namespace: string) {
|
||||
return `${namespace}.smoke_runs`;
|
||||
}
|
||||
|
||||
function stringField(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
readSmokeSummary = async function readSummary(companyId: string, issueId: string): Promise<SmokeSummary | null> {
|
||||
const rows = await ctx.db.query<{
|
||||
root_issue_id: string;
|
||||
child_issue_id: string | null;
|
||||
blocker_issue_id: string | null;
|
||||
billing_code: string;
|
||||
issue_title: string;
|
||||
last_summary: unknown;
|
||||
}>(
|
||||
`SELECT s.root_issue_id, s.child_issue_id, s.blocker_issue_id, s.billing_code, i.title AS issue_title, s.last_summary
|
||||
FROM ${tableName(ctx.db.namespace)} s
|
||||
JOIN public.issues i ON i.id = s.root_issue_id
|
||||
WHERE s.root_issue_id = $1`,
|
||||
[issueId],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
const orchestration = await ctx.issues.summaries.getOrchestration({
|
||||
issueId,
|
||||
companyId,
|
||||
includeSubtree: true,
|
||||
billingCode: row.billing_code,
|
||||
});
|
||||
return {
|
||||
rootIssueId: row.root_issue_id,
|
||||
childIssueId: row.child_issue_id,
|
||||
blockerIssueId: row.blocker_issue_id,
|
||||
billingCode: row.billing_code,
|
||||
joinedRows: rows,
|
||||
subtreeIssueIds: orchestration.subtreeIssueIds,
|
||||
wakeupQueued: Boolean((row.last_summary as { wakeupQueued?: unknown } | null)?.wakeupQueued),
|
||||
};
|
||||
};
|
||||
|
||||
initializeSmoke = async function runSmoke(input: SmokeInput): Promise<SmokeSummary> {
|
||||
const root = await ctx.issues.get(input.issueId, input.companyId);
|
||||
if (!root) throw new Error(`Issue not found: ${input.issueId}`);
|
||||
|
||||
const billingCode = `plugin-smoke:${input.issueId}`;
|
||||
const actor = {
|
||||
actorAgentId: input.actorAgentId ?? null,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorRunId: input.actorRunId ?? null,
|
||||
};
|
||||
const blocker = await ctx.issues.create({
|
||||
companyId: input.companyId,
|
||||
parentId: input.issueId,
|
||||
inheritExecutionWorkspaceFromIssueId: input.issueId,
|
||||
title: "Orchestration smoke blocker",
|
||||
description: "Resolved blocker used to verify plugin relation writes without preventing the smoke wakeup.",
|
||||
status: "done",
|
||||
priority: "low",
|
||||
billingCode,
|
||||
originKind: `plugin:${ctx.manifest.id}:blocker`,
|
||||
originId: `${input.issueId}:blocker`,
|
||||
actor,
|
||||
});
|
||||
|
||||
const child = await ctx.issues.create({
|
||||
companyId: input.companyId,
|
||||
parentId: input.issueId,
|
||||
inheritExecutionWorkspaceFromIssueId: input.issueId,
|
||||
title: "Orchestration smoke child",
|
||||
description: "Generated by the orchestration smoke plugin to verify issue, document, relation, wakeup, and summary APIs.",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: input.assigneeAgentId ?? root.assigneeAgentId ?? undefined,
|
||||
billingCode,
|
||||
originKind: `plugin:${ctx.manifest.id}:child`,
|
||||
originId: `${input.issueId}:child`,
|
||||
blockedByIssueIds: [blocker.id],
|
||||
actor,
|
||||
});
|
||||
|
||||
await ctx.issues.relations.setBlockedBy(child.id, [blocker.id], input.companyId, actor);
|
||||
await ctx.issues.documents.upsert({
|
||||
issueId: child.id,
|
||||
companyId: input.companyId,
|
||||
key: "orchestration-smoke",
|
||||
title: "Orchestration Smoke",
|
||||
format: "markdown",
|
||||
body: [
|
||||
"# Orchestration Smoke",
|
||||
"",
|
||||
`- Root issue: ${input.issueId}`,
|
||||
`- Child issue: ${child.id}`,
|
||||
`- Billing code: ${billingCode}`,
|
||||
].join("\n"),
|
||||
changeSummary: "Recorded orchestration smoke output",
|
||||
});
|
||||
|
||||
const wakeup = await ctx.issues.requestWakeup(child.id, input.companyId, {
|
||||
reason: "plugin:orchestration_smoke",
|
||||
contextSource: "plugin-orchestration-smoke",
|
||||
idempotencyKey: `${input.issueId}:child`,
|
||||
...actor,
|
||||
});
|
||||
const orchestration = await ctx.issues.summaries.getOrchestration({
|
||||
issueId: input.issueId,
|
||||
companyId: input.companyId,
|
||||
includeSubtree: true,
|
||||
billingCode,
|
||||
});
|
||||
const summarySnapshot = {
|
||||
childIssueId: child.id,
|
||||
blockerIssueId: blocker.id,
|
||||
wakeupQueued: wakeup.queued,
|
||||
subtreeIssueIds: orchestration.subtreeIssueIds,
|
||||
};
|
||||
|
||||
await ctx.db.execute(
|
||||
`INSERT INTO ${tableName(ctx.db.namespace)} (id, root_issue_id, child_issue_id, blocker_issue_id, billing_code, last_summary)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
child_issue_id = EXCLUDED.child_issue_id,
|
||||
blocker_issue_id = EXCLUDED.blocker_issue_id,
|
||||
billing_code = EXCLUDED.billing_code,
|
||||
last_summary = EXCLUDED.last_summary,
|
||||
updated_at = now()`,
|
||||
[
|
||||
randomUUID(),
|
||||
input.issueId,
|
||||
child.id,
|
||||
blocker.id,
|
||||
billingCode,
|
||||
JSON.stringify(summarySnapshot),
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
rootIssueId: input.issueId,
|
||||
childIssueId: child.id,
|
||||
blockerIssueId: blocker.id,
|
||||
billingCode,
|
||||
joinedRows: await ctx.db.query(
|
||||
`SELECT s.id, s.billing_code, i.title AS root_title
|
||||
FROM ${tableName(ctx.db.namespace)} s
|
||||
JOIN public.issues i ON i.id = s.root_issue_id
|
||||
WHERE s.root_issue_id = $1`,
|
||||
[input.issueId],
|
||||
),
|
||||
subtreeIssueIds: orchestration.subtreeIssueIds,
|
||||
wakeupQueued: wakeup.queued,
|
||||
};
|
||||
};
|
||||
|
||||
ctx.data.register("surface-status", async (params) => {
|
||||
const companyId = stringField(params.companyId);
|
||||
const issueId = stringField(params.issueId);
|
||||
return {
|
||||
status: "ok",
|
||||
checkedAt: new Date().toISOString(),
|
||||
databaseNamespace: ctx.db.namespace,
|
||||
routeKeys: (ctx.manifest.apiRoutes ?? []).map((route) => route.routeKey),
|
||||
capabilities: ctx.manifest.capabilities,
|
||||
summary: companyId && issueId ? await readSmokeSummary?.(companyId, issueId) ?? null : null,
|
||||
};
|
||||
});
|
||||
|
||||
ctx.actions.register("initialize-smoke", async (params) => {
|
||||
const companyId = stringField(params.companyId);
|
||||
const issueId = stringField(params.issueId);
|
||||
if (!companyId || !issueId) throw new Error("companyId and issueId are required");
|
||||
if (!initializeSmoke) throw new Error("Smoke initializer is not ready");
|
||||
return initializeSmoke({
|
||||
companyId,
|
||||
issueId,
|
||||
assigneeAgentId: stringField(params.assigneeAgentId),
|
||||
actorAgentId: stringField(params.actorAgentId),
|
||||
actorUserId: stringField(params.actorUserId),
|
||||
actorRunId: stringField(params.actorRunId),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async onApiRequest(input: PluginApiRequestInput) {
|
||||
if (input.routeKey === "summary") {
|
||||
const issueId = input.params.issueId;
|
||||
return {
|
||||
body: await readSmokeSummary?.(input.companyId, issueId) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeKey === "initialize") {
|
||||
if (!initializeSmoke) throw new Error("Smoke initializer is not ready");
|
||||
const body = input.body as Record<string, unknown> | null;
|
||||
return {
|
||||
status: 201,
|
||||
body: await initializeSmoke({
|
||||
companyId: input.companyId,
|
||||
issueId: input.params.issueId,
|
||||
assigneeAgentId: stringField(body?.assigneeAgentId),
|
||||
actorAgentId: input.actor.agentId ?? null,
|
||||
actorUserId: input.actor.userId ?? null,
|
||||
actorRunId: input.actor.runId ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: `Unknown orchestration smoke route: ${input.routeKey}` },
|
||||
};
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Orchestration smoke plugin worker is running",
|
||||
details: {
|
||||
surfaces: ["database", "scoped-api-route", "issue-panel", "orchestration-apis"],
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,162 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pluginManifestV1Schema, type Issue } from "@paperclipai/shared";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
function issue(input: Partial<Issue> & Pick<Issue, "id" | "companyId" | "title">): Issue {
|
||||
const now = new Date();
|
||||
const { id, companyId, title, ...rest } = input;
|
||||
return {
|
||||
id,
|
||||
companyId,
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title,
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: null,
|
||||
originKind: "manual",
|
||||
originId: null,
|
||||
originRunId: null,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
describe("orchestration smoke plugin", () => {
|
||||
it("declares the Phase 1 orchestration surfaces", () => {
|
||||
expect(pluginManifestV1Schema.parse(manifest)).toMatchObject({
|
||||
id: "paperclipai.plugin-orchestration-smoke-example",
|
||||
database: {
|
||||
migrationsDir: "migrations",
|
||||
coreReadTables: ["issues"],
|
||||
},
|
||||
apiRoutes: [
|
||||
expect.objectContaining({ routeKey: "initialize" }),
|
||||
expect.objectContaining({ routeKey: "summary" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates plugin-owned orchestration rows, issue tree, document, wakeup, and summary reads", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
issues: [
|
||||
issue({
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Root orchestration issue",
|
||||
assigneeAgentId: agentId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.performAction<{
|
||||
rootIssueId: string;
|
||||
childIssueId: string;
|
||||
blockerIssueId: string;
|
||||
billingCode: string;
|
||||
subtreeIssueIds: string[];
|
||||
wakeupQueued: boolean;
|
||||
}>("initialize-smoke", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
expect(result.rootIssueId).toBe(rootIssueId);
|
||||
expect(result.childIssueId).toEqual(expect.any(String));
|
||||
expect(result.blockerIssueId).toEqual(expect.any(String));
|
||||
expect(result.billingCode).toBe(`plugin-smoke:${rootIssueId}`);
|
||||
expect(result.wakeupQueued).toBe(true);
|
||||
expect(result.subtreeIssueIds).toEqual(expect.arrayContaining([rootIssueId, result.childIssueId]));
|
||||
expect(harness.dbExecutes[0]?.sql).toContain(".smoke_runs");
|
||||
expect(harness.dbQueries.some((entry) => entry.sql.includes("JOIN public.issues"))).toBe(true);
|
||||
|
||||
const relations = await harness.ctx.issues.relations.get(result.childIssueId, companyId);
|
||||
expect(relations.blockedBy).toEqual([
|
||||
expect.objectContaining({
|
||||
id: result.blockerIssueId,
|
||||
status: "done",
|
||||
}),
|
||||
]);
|
||||
const docs = await harness.ctx.issues.documents.list(result.childIssueId, companyId);
|
||||
expect(docs).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "orchestration-smoke",
|
||||
title: "Orchestration Smoke",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("dispatches the scoped API route through the same smoke path", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({
|
||||
issues: [
|
||||
issue({
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Scoped API root",
|
||||
assigneeAgentId: agentId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(plugin.definition.onApiRequest?.({
|
||||
routeKey: "initialize",
|
||||
method: "POST",
|
||||
path: `/issues/${rootIssueId}/smoke`,
|
||||
params: { issueId: rootIssueId },
|
||||
query: {},
|
||||
body: { assigneeAgentId: agentId },
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
userId: "board",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
},
|
||||
companyId,
|
||||
headers: {},
|
||||
})).resolves.toMatchObject({
|
||||
status: 201,
|
||||
body: expect.objectContaining({
|
||||
rootIssueId,
|
||||
wakeupQueued: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user