Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: (51 commits) Use attachment-size limit for company logos Address Greptile company logo feedback Drop lockfile from PR branch Use asset-backed company logos fix: use appType "custom" for Vite dev server so worktree branding is applied docs: fix documentation drift — adapters, plugins, tech stack docs: update documentation for accuracy after plugin system launch chore: ignore superset artifacts Dark theme for CodeMirror code blocks in MDXEditor Remove duplicate @paperclipai/adapter-openclaw-gateway in server/package.json Fix code block styles with robust prose overrides Add Docker setup for untrusted PR review in isolated containers Fix org chart canvas height to fit viewport without scrolling Add doc-maintenance skill for periodic documentation accuracy audits Fix sidebar scrollbar: hide track background when not hovering Restyle markdown code blocks: dark background, smaller font, compact padding Add archive project button and filter archived projects from selectors fix: address review feedback — subscription cleanup, filter nullability, stale diagram fix: wire plugin event subscriptions from worker to host fix(ui): hide scrollbar track background when sidebar is not hovered ... # Conflicts: # packages/db/src/migrations/meta/0030_snapshot.json # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
201
.agents/skills/doc-maintenance/SKILL.md
Normal file
201
.agents/skills/doc-maintenance/SKILL.md
Normal file
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: doc-maintenance
|
||||
description: >
|
||||
Audit top-level documentation (README, SPEC, PRODUCT) against recent git
|
||||
history to find drift — shipped features missing from docs or features
|
||||
listed as upcoming that already landed. Proposes minimal edits, creates
|
||||
a branch, and opens a PR. Use when asked to review docs for accuracy,
|
||||
after major feature merges, or on a periodic schedule.
|
||||
---
|
||||
|
||||
# Doc Maintenance Skill
|
||||
|
||||
Detect documentation drift and fix it via PR — no rewrites, no churn.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Periodic doc review (e.g. weekly or after releases)
|
||||
- After major feature merges
|
||||
- When asked "are our docs up to date?"
|
||||
- When asked to audit README / SPEC / PRODUCT accuracy
|
||||
|
||||
## Target Documents
|
||||
|
||||
| Document | Path | What matters |
|
||||
|----------|------|-------------|
|
||||
| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table |
|
||||
| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy |
|
||||
| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy |
|
||||
|
||||
Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files,
|
||||
release notes. These are dev-facing or ephemeral — lower risk of user-facing
|
||||
confusion.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Detect what changed
|
||||
|
||||
Find the last review cursor:
|
||||
|
||||
```bash
|
||||
# Read the last-reviewed commit SHA
|
||||
CURSOR_FILE=".doc-review-cursor"
|
||||
if [ -f "$CURSOR_FILE" ]; then
|
||||
LAST_SHA=$(cat "$CURSOR_FILE" | head -1)
|
||||
else
|
||||
# First run: look back 60 days
|
||||
LAST_SHA=$(git log --format="%H" --after="60 days ago" --reverse | head -1)
|
||||
fi
|
||||
```
|
||||
|
||||
Then gather commits since the cursor:
|
||||
|
||||
```bash
|
||||
git log "$LAST_SHA"..HEAD --oneline --no-merges
|
||||
```
|
||||
|
||||
### Step 2 — Classify changes
|
||||
|
||||
Scan commit messages and changed files. Categorize into:
|
||||
|
||||
- **Feature** — new capabilities (keywords: `feat`, `add`, `implement`, `support`)
|
||||
- **Breaking** — removed/renamed things (keywords: `remove`, `breaking`, `drop`, `rename`)
|
||||
- **Structural** — new directories, config changes, new adapters, new CLI commands
|
||||
|
||||
**Ignore:** refactors, test-only changes, CI config, dependency bumps, doc-only
|
||||
changes, style/formatting commits. These don't affect doc accuracy.
|
||||
|
||||
For borderline cases, check the actual diff — a commit titled "refactor: X"
|
||||
that adds a new public API is a feature.
|
||||
|
||||
### Step 3 — Build a change summary
|
||||
|
||||
Produce a concise list like:
|
||||
|
||||
```
|
||||
Since last review (<sha>, <date>):
|
||||
- FEATURE: Plugin system merged (runtime, SDK, CLI, slots, event bridge)
|
||||
- FEATURE: Project archiving added
|
||||
- BREAKING: Removed legacy webhook adapter
|
||||
- STRUCTURAL: New .agents/skills/ directory convention
|
||||
```
|
||||
|
||||
If there are no notable changes, skip to Step 7 (update cursor and exit).
|
||||
|
||||
### Step 4 — Audit each target doc
|
||||
|
||||
For each target document, read it fully and cross-reference against the change
|
||||
summary. Check for:
|
||||
|
||||
1. **False negatives** — major shipped features not mentioned at all
|
||||
2. **False positives** — features listed as "coming soon" / "roadmap" / "planned"
|
||||
/ "not supported" / "TBD" that already shipped
|
||||
3. **Quickstart accuracy** — install commands, prereqs, and startup instructions
|
||||
still correct (README only)
|
||||
4. **Feature table accuracy** — does the features section reflect current
|
||||
capabilities? (README only)
|
||||
5. **Works-with accuracy** — are supported adapters/integrations listed correctly?
|
||||
|
||||
Use `references/audit-checklist.md` as the structured checklist.
|
||||
Use `references/section-map.md` to know where to look for each feature area.
|
||||
|
||||
### Step 5 — Create branch and apply minimal edits
|
||||
|
||||
```bash
|
||||
# Create a branch for the doc updates
|
||||
BRANCH="docs/maintenance-$(date +%Y%m%d)"
|
||||
git checkout -b "$BRANCH"
|
||||
```
|
||||
|
||||
Apply **only** the edits needed to fix drift. Rules:
|
||||
|
||||
- **Minimal patches only.** Fix inaccuracies, don't rewrite sections.
|
||||
- **Preserve voice and style.** Match the existing tone of each document.
|
||||
- **No cosmetic changes.** Don't fix typos, reformat tables, or reorganize
|
||||
sections unless they're part of a factual fix.
|
||||
- **No new sections.** If a feature needs a whole new section, note it in the
|
||||
PR description as a follow-up — don't add it in a maintenance pass.
|
||||
- **Roadmap items:** Move shipped features out of Roadmap. Add a brief mention
|
||||
in the appropriate existing section if there isn't one already. Don't add
|
||||
long descriptions.
|
||||
|
||||
### Step 6 — Open a PR
|
||||
|
||||
Commit the changes and open a PR:
|
||||
|
||||
```bash
|
||||
git add README.md doc/SPEC.md doc/PRODUCT.md .doc-review-cursor
|
||||
git commit -m "docs: update documentation for accuracy
|
||||
|
||||
- [list each fix briefly]
|
||||
|
||||
Co-Authored-By: Paperclip <noreply@paperclip.ing>"
|
||||
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
gh pr create \
|
||||
--title "docs: periodic documentation accuracy update" \
|
||||
--body "$(cat <<'EOF'
|
||||
## Summary
|
||||
Automated doc maintenance pass. Fixes documentation drift detected since
|
||||
last review.
|
||||
|
||||
### Changes
|
||||
- [list each fix]
|
||||
|
||||
### Change summary (since last review)
|
||||
- [list notable code changes that triggered doc updates]
|
||||
|
||||
## Review notes
|
||||
- Only factual accuracy fixes — no style/cosmetic changes
|
||||
- Preserves existing voice and structure
|
||||
- Larger doc additions (new sections, tutorials) noted as follow-ups
|
||||
|
||||
🤖 Generated by doc-maintenance skill
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Step 7 — Update the cursor
|
||||
|
||||
After a successful audit (whether or not edits were needed), update the cursor:
|
||||
|
||||
```bash
|
||||
git rev-parse HEAD > .doc-review-cursor
|
||||
```
|
||||
|
||||
If edits were made, this is already committed in the PR branch. If no edits
|
||||
were needed, commit the cursor update to the current branch.
|
||||
|
||||
## Change Classification Rules
|
||||
|
||||
| Signal | Category | Doc update needed? |
|
||||
|--------|----------|-------------------|
|
||||
| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing |
|
||||
| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes |
|
||||
| New top-level directory or config file | Structural | Maybe |
|
||||
| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) |
|
||||
| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No |
|
||||
| `docs:` | Doc change | No (already handled) |
|
||||
| Dependency bumps only | Maintenance | No |
|
||||
|
||||
## Patch Style Guide
|
||||
|
||||
- Fix the fact, not the prose
|
||||
- If removing a roadmap item, don't leave a gap — remove the bullet cleanly
|
||||
- If adding a feature mention, match the format of surrounding entries
|
||||
(e.g. if features are in a table, add a table row)
|
||||
- Keep README changes especially minimal — it shouldn't churn often
|
||||
- For SPEC/PRODUCT, prefer updating existing statements over adding new ones
|
||||
(e.g. change "not supported in V1" to "supported via X" rather than adding
|
||||
a new section)
|
||||
|
||||
## Output
|
||||
|
||||
When the skill completes, report:
|
||||
|
||||
- How many commits were scanned
|
||||
- How many notable changes were found
|
||||
- How many doc edits were made (and to which files)
|
||||
- PR link (if edits were made)
|
||||
- Any follow-up items that need larger doc work
|
||||
85
.agents/skills/doc-maintenance/references/audit-checklist.md
Normal file
85
.agents/skills/doc-maintenance/references/audit-checklist.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Doc Maintenance Audit Checklist
|
||||
|
||||
Use this checklist when auditing each target document. For each item, compare
|
||||
against the change summary from git history.
|
||||
|
||||
## README.md
|
||||
|
||||
### Features table
|
||||
- [ ] Each feature card reflects a shipped capability
|
||||
- [ ] No feature cards for things that don't exist yet
|
||||
- [ ] No major shipped features missing from the table
|
||||
|
||||
### Roadmap
|
||||
- [ ] Nothing listed as "planned" or "coming soon" that already shipped
|
||||
- [ ] No removed/cancelled items still listed
|
||||
- [ ] Items reflect current priorities (cross-check with recent PRs)
|
||||
|
||||
### Quickstart
|
||||
- [ ] `npx paperclipai onboard` command is correct
|
||||
- [ ] Manual install steps are accurate (clone URL, commands)
|
||||
- [ ] Prerequisites (Node version, pnpm version) are current
|
||||
- [ ] Server URL and port are correct
|
||||
|
||||
### "What is Paperclip" section
|
||||
- [ ] High-level description is accurate
|
||||
- [ ] Step table (Define goal / Hire team / Approve and run) is correct
|
||||
|
||||
### "Works with" table
|
||||
- [ ] All supported adapters/runtimes are listed
|
||||
- [ ] No removed adapters still listed
|
||||
- [ ] Logos and labels match current adapter names
|
||||
|
||||
### "Paperclip is right for you if"
|
||||
- [ ] Use cases are still accurate
|
||||
- [ ] No claims about capabilities that don't exist
|
||||
|
||||
### "Why Paperclip is special"
|
||||
- [ ] Technical claims are accurate (atomic execution, governance, etc.)
|
||||
- [ ] No features listed that were removed or significantly changed
|
||||
|
||||
### FAQ
|
||||
- [ ] Answers are still correct
|
||||
- [ ] No references to removed features or outdated behavior
|
||||
|
||||
### Development section
|
||||
- [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.)
|
||||
- [ ] Link to DEVELOPING.md is correct
|
||||
|
||||
## doc/SPEC.md
|
||||
|
||||
### Company Model
|
||||
- [ ] Fields match current schema
|
||||
- [ ] Governance model description is accurate
|
||||
|
||||
### Agent Model
|
||||
- [ ] Adapter types match what's actually supported
|
||||
- [ ] Agent configuration description is accurate
|
||||
- [ ] No features described as "not supported" or "not V1" that shipped
|
||||
|
||||
### Task Model
|
||||
- [ ] Task hierarchy description is accurate
|
||||
- [ ] Status values match current implementation
|
||||
|
||||
### Extensions / Plugins
|
||||
- [ ] If plugins are shipped, no "not in V1" or "future" language
|
||||
- [ ] Plugin model description matches implementation
|
||||
|
||||
### Open Questions
|
||||
- [ ] Resolved questions removed or updated
|
||||
- [ ] No "TBD" items that have been decided
|
||||
|
||||
## doc/PRODUCT.md
|
||||
|
||||
### Core Concepts
|
||||
- [ ] Company, Employees, Task Management descriptions accurate
|
||||
- [ ] Agent Execution modes described correctly
|
||||
- [ ] No missing major concepts
|
||||
|
||||
### Principles
|
||||
- [ ] Principles haven't been contradicted by shipped features
|
||||
- [ ] No principles referencing removed capabilities
|
||||
|
||||
### User Flow
|
||||
- [ ] Dream scenario still reflects actual onboarding
|
||||
- [ ] Steps are achievable with current features
|
||||
22
.agents/skills/doc-maintenance/references/section-map.md
Normal file
22
.agents/skills/doc-maintenance/references/section-map.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Section Map
|
||||
|
||||
Maps feature areas to specific document sections so the skill knows where to
|
||||
look when a feature ships or changes.
|
||||
|
||||
| Feature Area | README Section | SPEC Section | PRODUCT Section |
|
||||
|-------------|---------------|-------------|----------------|
|
||||
| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts |
|
||||
| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution |
|
||||
| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles |
|
||||
| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) |
|
||||
| Task Management | Features table | Task Model | Task Management |
|
||||
| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents |
|
||||
| Multi-Company | Features table, FAQ | Company Model | Company |
|
||||
| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution |
|
||||
| CLI Commands | Development section | — | — |
|
||||
| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow |
|
||||
| Skills / Skill Injection | "Why special" | — | — |
|
||||
| Company Templates | "Why special", Roadmap (ClipMart) | — | — |
|
||||
| Mobile / UI | Features table | — | — |
|
||||
| Project Archiving | — | — | — |
|
||||
| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution |
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -37,7 +37,13 @@ tmp/
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
.paperclip-local/
|
||||
/.idea/
|
||||
/.agents/
|
||||
|
||||
# Doc maintenance cursor
|
||||
.doc-review-cursor
|
||||
|
||||
# Playwright
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/playwright-report/
|
||||
.superset/
|
||||
|
||||
@@ -239,7 +239,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
- ⚪ ClipMart - buy and sell entire agent companies
|
||||
- ⚪ Easy agent configurations / easier to understand
|
||||
- ⚪ Better support for harness engineering
|
||||
- ⚪ Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||
- ⚪ Better docs
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -14,6 +14,8 @@ function makeCompany(overrides: Partial<Company>): Company {
|
||||
spentMonthlyCents: 0,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
|
||||
374
cli/src/commands/client/plugin.ts
Normal file
374
cli/src/commands/client/plugin.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types mirroring server-side shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginRecord {
|
||||
id: string;
|
||||
pluginKey: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
status: string;
|
||||
displayName?: string;
|
||||
lastError?: string | null;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginListOptions extends BaseClientOptions {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PluginInstallOptions extends BaseClientOptions {
|
||||
local?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PluginUninstallOptions extends BaseClientOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a local path argument to an absolute path so the server can find the
|
||||
* plugin on disk regardless of where the user ran the CLI.
|
||||
*/
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||
if (!isLocal) return packageArg;
|
||||
// Already absolute
|
||||
if (path.isAbsolute(packageArg)) return packageArg;
|
||||
// Expand leading ~ to home directory
|
||||
if (packageArg.startsWith("~")) {
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
return path.resolve(process.cwd(), packageArg);
|
||||
}
|
||||
|
||||
function formatPlugin(p: PluginRecord): string {
|
||||
const statusColor =
|
||||
p.status === "ready"
|
||||
? pc.green(p.status)
|
||||
: p.status === "error"
|
||||
? pc.red(p.status)
|
||||
: p.status === "disabled"
|
||||
? pc.dim(p.status)
|
||||
: pc.yellow(p.status);
|
||||
|
||||
const parts = [
|
||||
`key=${pc.bold(p.pluginKey)}`,
|
||||
`status=${statusColor}`,
|
||||
`version=${p.version}`,
|
||||
`id=${pc.dim(p.id)}`,
|
||||
];
|
||||
|
||||
if (p.lastError) {
|
||||
parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin list
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("list")
|
||||
.description("List installed plugins")
|
||||
.option("--status <status>", "Filter by status (ready, error, disabled, installed, upgrade_pending)")
|
||||
.action(async (opts: PluginListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
|
||||
const plugins = await ctx.api.get<PluginRecord[]>(`/api/plugins${qs}`);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(plugins, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = plugins ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No plugins installed."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of rows) {
|
||||
console.log(formatPlugin(p));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin install <package-or-path>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("install <package>")
|
||||
.description(
|
||||
"Install a plugin from a local path or npm package.\n" +
|
||||
" Examples:\n" +
|
||||
" paperclipai plugin install ./my-plugin # local path\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear # npm package\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear@1.2 # pinned version",
|
||||
)
|
||||
.option("-l, --local", "Treat <package> as a local filesystem path", false)
|
||||
.option("--version <version>", "Specific npm version to install (npm packages only)")
|
||||
.action(async (packageArg: string, opts: PluginInstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("/") ||
|
||||
packageArg.startsWith("~");
|
||||
|
||||
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
isLocal
|
||||
? `Installing plugin from local path: ${resolvedPackage}`
|
||||
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(installedPlugin, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!installedPlugin) {
|
||||
console.log(pc.dim("Install returned no plugin record."));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
pc.green(
|
||||
`✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`,
|
||||
),
|
||||
);
|
||||
|
||||
if (installedPlugin.lastError) {
|
||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin uninstall <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("uninstall <pluginKey>")
|
||||
.description(
|
||||
"Uninstall a plugin by its plugin key or database ID.\n" +
|
||||
" Use --force to hard-purge all state and config.",
|
||||
)
|
||||
.option("--force", "Purge all plugin state and config (hard delete)", false)
|
||||
.action(async (pluginKey: string, opts: PluginUninstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const purge = opts.force === true;
|
||||
const qs = purge ? "?purge=true" : "";
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
purge
|
||||
? `Uninstalling and purging plugin: ${pluginKey}`
|
||||
: `Uninstalling plugin: ${pluginKey}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ctx.api.delete<PluginRecord | null>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}${qs}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin enable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("enable <pluginKey>")
|
||||
.description("Enable a disabled or errored plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/enable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin disable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("disable <pluginKey>")
|
||||
.description("Disable a running plugin without uninstalling it")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/disable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin inspect <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("inspect <pluginKey>")
|
||||
.description("Show full details for an installed plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.get<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
console.log(pc.red(`Plugin not found: ${pluginKey}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(formatPlugin(result));
|
||||
if (result.lastError) {
|
||||
console.log(`\n${pc.red("Last error:")}\n${result.lastError}`);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin examples
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("examples")
|
||||
.description("List bundled example plugins available for local install")
|
||||
.action(async (opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const examples = await ctx.api.get<
|
||||
Array<{
|
||||
packageName: string;
|
||||
pluginKey: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
tag: string;
|
||||
}>
|
||||
>("/api/plugins/examples");
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(examples, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = examples ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No bundled examples available."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ex of rows) {
|
||||
console.log(
|
||||
`${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` +
|
||||
` ${ex.description}\n` +
|
||||
` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -136,6 +137,7 @@ registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ docker compose -f docker-compose.quickstart.yml up --build
|
||||
|
||||
See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details.
|
||||
|
||||
## Docker For Untrusted PR Review
|
||||
|
||||
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||
|
||||
## Database in Dev (Auto-Handled)
|
||||
|
||||
For local development, leave `DATABASE_URL` unset.
|
||||
|
||||
@@ -93,6 +93,12 @@ Notes:
|
||||
- Without API keys, the app still runs normally.
|
||||
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
|
||||
|
||||
## Untrusted PR Review Container
|
||||
|
||||
If you want a separate Docker environment for reviewing untrusted pull requests with `codex` or `claude`, use the dedicated review workflow in `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||
|
||||
That setup keeps CLI auth state in Docker volumes instead of your host home directory and uses a separate scratch workspace for PR checkouts and preview runs.
|
||||
|
||||
## Onboard Smoke Test (Ubuntu + npm only)
|
||||
|
||||
Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:
|
||||
|
||||
15
doc/SPEC.md
15
doc/SPEC.md
@@ -188,12 +188,15 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
|
||||
|
||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
||||
|
||||
| Adapter | Mechanism | Example |
|
||||
| --------- | ----------------------- | --------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| Adapter | Mechanism | Example |
|
||||
| -------------------- | ----------------------- | --------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
|
||||
| `hermes_local` | Hermes agent process | Local Hermes agent |
|
||||
|
||||
The `process` and `http` adapters ship as defaults. Additional adapters can be added via the plugin system (see Plugin / Extension Architecture).
|
||||
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||
|
||||
### Adapter Interface
|
||||
|
||||
@@ -429,7 +432,7 @@ The core Paperclip system must be extensible. Features like knowledge bases, ext
|
||||
- **Agent Adapter plugins** — new Adapter types can be registered via the plugin system
|
||||
- Plugin-registrable UI components (future)
|
||||
|
||||
This isn't a V1 deliverable (we're not building a plugin framework upfront), but the architecture should not paint us into a corner. Keep boundaries clean so extensions are possible.
|
||||
The plugin framework has shipped. Plugins can register new adapter types, hook into lifecycle events, and contribute UI components (e.g. global toolbar buttons). A plugin SDK and CLI commands (`paperclipai plugin`) are available for authoring and installing plugins.
|
||||
|
||||
---
|
||||
|
||||
|
||||
135
doc/UNTRUSTED-PR-REVIEW.md
Normal file
135
doc/UNTRUSTED-PR-REVIEW.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Untrusted PR Review In Docker
|
||||
|
||||
Use this workflow when you want Codex or Claude to inspect a pull request that you do not want touching your host machine directly.
|
||||
|
||||
This is intentionally separate from the normal Paperclip dev image.
|
||||
|
||||
## What this container isolates
|
||||
|
||||
- `codex` auth/session state in a Docker volume, not your host `~/.codex`
|
||||
- `claude` auth/session state in a Docker volume, not your host `~/.claude`
|
||||
- `gh` auth state in the same container-local home volume
|
||||
- review clones, worktrees, dependency installs, and local databases in a writable scratch volume under `/work`
|
||||
|
||||
By default this workflow does **not** mount your host repo checkout, your host home directory, or your SSH agent.
|
||||
|
||||
## Files
|
||||
|
||||
- `docker/untrusted-review/Dockerfile`
|
||||
- `docker-compose.untrusted-review.yml`
|
||||
- `review-checkout-pr` inside the container
|
||||
|
||||
## Build and start a shell
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.untrusted-review.yml build
|
||||
docker compose -f docker-compose.untrusted-review.yml run --rm --service-ports review
|
||||
```
|
||||
|
||||
That opens an interactive shell in the review container with:
|
||||
|
||||
- Node + Corepack/pnpm
|
||||
- `codex`
|
||||
- `claude`
|
||||
- `gh`
|
||||
- `git`, `rg`, `fd`, `jq`
|
||||
|
||||
## First-time login inside the container
|
||||
|
||||
Run these once. The resulting login state persists in the `review-home` Docker volume.
|
||||
|
||||
```sh
|
||||
gh auth login
|
||||
codex login
|
||||
claude login
|
||||
```
|
||||
|
||||
If you prefer API-key auth instead of CLI login, pass keys through Compose env:
|
||||
|
||||
```sh
|
||||
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker-compose.untrusted-review.yml run --rm review
|
||||
```
|
||||
|
||||
## Check out a PR safely
|
||||
|
||||
Inside the container:
|
||||
|
||||
```sh
|
||||
review-checkout-pr paperclipai/paperclip 432
|
||||
cd /work/checkouts/paperclipai-paperclip/pr-432
|
||||
```
|
||||
|
||||
What this does:
|
||||
|
||||
1. Creates or reuses a repo clone under `/work/repos/...`
|
||||
2. Fetches `pull/<pr>/head` from GitHub
|
||||
3. Creates a detached git worktree under `/work/checkouts/...`
|
||||
|
||||
The checkout lives entirely inside the container volume.
|
||||
|
||||
## Ask Codex or Claude to review it
|
||||
|
||||
Inside the PR checkout:
|
||||
|
||||
```sh
|
||||
codex
|
||||
```
|
||||
|
||||
Then give it a prompt like:
|
||||
|
||||
```text
|
||||
Review this PR as hostile input. Focus on security issues, data exfiltration paths, sandbox escapes, dangerous install/runtime scripts, auth changes, and subtle behavioral regressions. Do not modify files. Produce findings ordered by severity with file references.
|
||||
```
|
||||
|
||||
Or with Claude:
|
||||
|
||||
```sh
|
||||
claude
|
||||
```
|
||||
|
||||
## Preview the Paperclip app from the PR
|
||||
|
||||
Only do this when you intentionally want to execute the PR's code inside the container.
|
||||
|
||||
Inside the PR checkout:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
HOST=0.0.0.0 pnpm dev
|
||||
```
|
||||
|
||||
Open from the host:
|
||||
|
||||
- `http://localhost:3100`
|
||||
|
||||
The Compose file also exposes Vite's default port:
|
||||
|
||||
- `http://localhost:5173`
|
||||
|
||||
Notes:
|
||||
|
||||
- `pnpm install` can run untrusted lifecycle scripts from the PR. That is why this happens inside the isolated container instead of on your host.
|
||||
- If you only want static inspection, do not run install/dev commands.
|
||||
- Paperclip's embedded PostgreSQL and local storage stay inside the container home volume via `PAPERCLIP_HOME=/home/reviewer/.paperclip-review`.
|
||||
|
||||
## Reset state
|
||||
|
||||
Remove the review container volumes when you want a clean environment:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.untrusted-review.yml down -v
|
||||
```
|
||||
|
||||
That deletes:
|
||||
|
||||
- Codex/Claude/GitHub login state stored in `review-home`
|
||||
- cloned repos, worktrees, installs, and scratch data stored in `review-work`
|
||||
|
||||
## Security limits
|
||||
|
||||
This is a useful isolation boundary, but it is still Docker, not a full VM.
|
||||
|
||||
- A reviewed PR can still access the container's network unless you disable it.
|
||||
- Any secrets you pass into the container are available to code you execute inside it.
|
||||
- Do not mount your host repo, host home, `.ssh`, or Docker socket unless you are intentionally weakening the boundary.
|
||||
- If you need a stronger boundary than this, use a disposable VM instead of Docker.
|
||||
@@ -108,6 +108,7 @@ Mount surfaces currently wired in the host include:
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
- `projectSidebarItem`
|
||||
- `globalToolbarButton`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
- `commentAnnotation`
|
||||
|
||||
33
docker-compose.untrusted-review.yml
Normal file
33
docker-compose.untrusted-review.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
review:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/untrusted-review/Dockerfile
|
||||
init: true
|
||||
tty: true
|
||||
stdin_open: true
|
||||
working_dir: /work
|
||||
environment:
|
||||
HOME: "/home/reviewer"
|
||||
CODEX_HOME: "/home/reviewer/.codex"
|
||||
CLAUDE_HOME: "/home/reviewer/.claude"
|
||||
PAPERCLIP_HOME: "/home/reviewer/.paperclip-review"
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
|
||||
ports:
|
||||
- "${REVIEW_PAPERCLIP_PORT:-3100}:3100"
|
||||
- "${REVIEW_VITE_PORT:-5173}:5173"
|
||||
volumes:
|
||||
- review-home:/home/reviewer
|
||||
- review-work:/work
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp:mode=1777,size=1g
|
||||
|
||||
volumes:
|
||||
review-home:
|
||||
review-work:
|
||||
44
docker/untrusted-review/Dockerfile
Normal file
44
docker/untrusted-review/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
FROM node:lts-trixie-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
fd-find \
|
||||
gh \
|
||||
git \
|
||||
jq \
|
||||
less \
|
||||
openssh-client \
|
||||
procps \
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
|
||||
|
||||
RUN corepack enable \
|
||||
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash reviewer
|
||||
|
||||
ENV HOME=/home/reviewer \
|
||||
CODEX_HOME=/home/reviewer/.codex \
|
||||
CLAUDE_HOME=/home/reviewer/.claude \
|
||||
PAPERCLIP_HOME=/home/reviewer/.paperclip-review \
|
||||
PNPM_HOME=/home/reviewer/.local/share/pnpm \
|
||||
PATH=/home/reviewer/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
COPY --chown=reviewer:reviewer docker/untrusted-review/bin/review-checkout-pr /usr/local/bin/review-checkout-pr
|
||||
|
||||
RUN chmod +x /usr/local/bin/review-checkout-pr \
|
||||
&& mkdir -p /work \
|
||||
&& chown -R reviewer:reviewer /work
|
||||
|
||||
USER reviewer
|
||||
|
||||
EXPOSE 3100 5173
|
||||
|
||||
CMD ["bash", "-l"]
|
||||
65
docker/untrusted-review/bin/review-checkout-pr
Normal file
65
docker/untrusted-review/bin/review-checkout-pr
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: review-checkout-pr <owner/repo|github-url> <pr-number> [checkout-dir]
|
||||
|
||||
Examples:
|
||||
review-checkout-pr paperclipai/paperclip 432
|
||||
review-checkout-pr https://github.com/paperclipai/paperclip.git 432
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -lt 2 || $# -gt 3 ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
normalize_repo_slug() {
|
||||
local raw="$1"
|
||||
raw="${raw#git@github.com:}"
|
||||
raw="${raw#ssh://git@github.com/}"
|
||||
raw="${raw#https://github.com/}"
|
||||
raw="${raw#http://github.com/}"
|
||||
raw="${raw%.git}"
|
||||
printf '%s\n' "${raw#/}"
|
||||
}
|
||||
|
||||
repo_slug="$(normalize_repo_slug "$1")"
|
||||
pr_number="$2"
|
||||
|
||||
if [[ ! "$repo_slug" =~ ^[^/]+/[^/]+$ ]]; then
|
||||
echo "Expected GitHub repo slug like owner/repo or a GitHub repo URL, got: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
|
||||
echo "PR number must be numeric, got: $pr_number" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
repo_key="${repo_slug//\//-}"
|
||||
mirror_dir="/work/repos/${repo_key}"
|
||||
checkout_dir="${3:-/work/checkouts/${repo_key}/pr-${pr_number}}"
|
||||
pr_ref="refs/remotes/origin/pr/${pr_number}"
|
||||
|
||||
mkdir -p "$(dirname "$mirror_dir")" "$(dirname "$checkout_dir")"
|
||||
|
||||
if [[ ! -d "$mirror_dir/.git" ]]; then
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
gh repo clone "$repo_slug" "$mirror_dir" -- --filter=blob:none
|
||||
else
|
||||
git clone --filter=blob:none "https://github.com/${repo_slug}.git" "$mirror_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
git -C "$mirror_dir" fetch --force origin "pull/${pr_number}/head:${pr_ref}"
|
||||
|
||||
if [[ -e "$checkout_dir" ]]; then
|
||||
printf '%s\n' "$checkout_dir"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git -C "$mirror_dir" worktree add --detach "$checkout_dir" "$pr_ref" >/dev/null
|
||||
printf '%s\n' "$checkout_dir"
|
||||
@@ -38,10 +38,33 @@ PATCH /api/companies/{companyId}
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"description": "Updated description",
|
||||
"budgetMonthlyCents": 100000
|
||||
"budgetMonthlyCents": 100000,
|
||||
"logoAssetId": "b9f5e911-6de5-4cd0-8dc6-a55a13bc02f6"
|
||||
}
|
||||
```
|
||||
|
||||
## Upload Company Logo
|
||||
|
||||
Upload an image for a company icon and store it as that company’s logo.
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/logo
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Valid image content types:
|
||||
|
||||
- `image/png`
|
||||
- `image/jpeg`
|
||||
- `image/jpg`
|
||||
- `image/webp`
|
||||
- `image/gif`
|
||||
- `image/svg+xml`
|
||||
|
||||
Company logo uploads use the normal Paperclip attachment size limit.
|
||||
|
||||
Then set the company logo by PATCHing the returned `assetId` into `logoAssetId`.
|
||||
|
||||
## Archive Company
|
||||
|
||||
```
|
||||
@@ -58,6 +81,8 @@ Archives a company. Archived companies are hidden from default listings.
|
||||
| `name` | string | Company name |
|
||||
| `description` | string | Company description |
|
||||
| `status` | string | `active`, `paused`, `archived` |
|
||||
| `logoAssetId` | string | Optional asset id for the stored logo image |
|
||||
| `logoUrl` | string | Optional Paperclip asset content path for the stored logo image |
|
||||
| `budgetMonthlyCents` | number | Monthly budget limit |
|
||||
| `createdAt` | string | ISO timestamp |
|
||||
| `updatedAt` | string | ISO timestamp |
|
||||
|
||||
12
packages/db/src/migrations/0030_rich_magneto.sql
Normal file
12
packages/db/src/migrations/0030_rich_magneto.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "company_logos" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"asset_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_logos" ADD CONSTRAINT "company_logos_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "company_logos" ADD CONSTRAINT "company_logos_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "company_logos_company_uq" ON "company_logos" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "company_logos_asset_uq" ON "company_logos" USING btree ("asset_id");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -215,8 +215,15 @@
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1773514110632,
|
||||
"tag": "0030_wild_lord_hawal",
|
||||
"when": 1773670925214,
|
||||
"tag": "0030_rich_magneto",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1773694724077,
|
||||
"tag": "0031_yielding_toad",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
18
packages/db/src/schema/company_logos.ts
Normal file
18
packages/db/src/schema/company_logos.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { pgTable, uuid, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { assets } from "./assets.js";
|
||||
|
||||
export const companyLogos = pgTable(
|
||||
"company_logos",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
assetId: uuid("asset_id").notNull().references(() => assets.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUq: uniqueIndex("company_logos_company_uq").on(table.companyId),
|
||||
assetUq: uniqueIndex("company_logos_asset_uq").on(table.assetId),
|
||||
}),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
export { companies } from "./companies.js";
|
||||
export { companyLogos } from "./company_logos.js";
|
||||
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
|
||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||
export { agents } from "./agents.js";
|
||||
|
||||
@@ -207,6 +207,7 @@ The same set of values is used as **slot types** (where a component mounts) and
|
||||
| `sidebarPanel` | Global | — |
|
||||
| `settingsPage` | Global | — |
|
||||
| `dashboardWidget` | Global | — |
|
||||
| `globalToolbarButton` | Global | — |
|
||||
| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
|
||||
| `taskDetailView` | Entity | (task/issue context) |
|
||||
| `commentAnnotation` | Entity | `comment` |
|
||||
@@ -253,9 +254,13 @@ A specialized slot rendered in the context of a task or issue detail view. Simil
|
||||
|
||||
A link or small component rendered **once per project** under that project's row in the sidebar Projects list. Use this to add project-scoped navigation entries (e.g. "Files", "Linear Sync") that deep-link into a plugin detail tab: `/:company/projects/:projectRef?tab=plugin:<key>:<slotId>`. Receives `PluginProjectSidebarItemProps` with `context.companyId` set to the active company, `context.entityId` set to the project id, and `context.entityType` set to `"project"`. Use the optional `order` field in the manifest slot to control sort position. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `globalToolbarButton`
|
||||
|
||||
A button rendered in the global top bar (breadcrumb bar) that appears on every page. Use this for company-wide actions that are not scoped to a specific entity — for example, a universal search trigger, a global sync status indicator, or a floating action that applies across the whole workspace. Receives only `context.companyId` and `context.companyPrefix`; no entity context is available. Requires the `ui.action.register` capability.
|
||||
|
||||
#### `toolbarButton`
|
||||
|
||||
A button rendered in the toolbar of a host surface (e.g. project detail, issue detail). Use this for short-lived, contextual actions like triggering a sync, opening a picker, or running a quick command. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
|
||||
A button rendered in the toolbar of an entity page (e.g. project detail, issue detail). Use this for short-lived, contextual actions scoped to the current entity — like triggering a project sync, opening a picker, or running a quick command on that entity. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId`, `context.entityId`, and `context.entityType`; declare `entityTypes` in the manifest to control which entity pages the button appears on. Requires the `ui.action.register` capability.
|
||||
|
||||
#### `contextMenuItem`
|
||||
|
||||
@@ -481,7 +486,9 @@ Each slot type receives a typed props object with `context: PluginHostContext`.
|
||||
| `sidebar` | `PluginSidebarProps` | — |
|
||||
| `settingsPage` | `PluginSettingsPageProps` | — |
|
||||
| `dashboardWidget` | `PluginWidgetProps` | — |
|
||||
| `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |
|
||||
| `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` |
|
||||
| `toolbarButton` | `PluginToolbarButtonProps` | `entityId: string`, `entityType: string` |
|
||||
| `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
|
||||
| `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
|
||||
| `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: "project"` |
|
||||
@@ -521,7 +528,7 @@ V1 does not provide a dedicated `modal` slot. Plugins can either:
|
||||
- declare concrete UI mount points in `ui.slots`
|
||||
- declare host-rendered entry points in `ui.launchers`
|
||||
|
||||
Supported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.
|
||||
Supported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `globalToolbarButton`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.
|
||||
|
||||
Declarative launcher example:
|
||||
|
||||
@@ -597,7 +604,14 @@ Use optional `order` in the slot to sort among other project sidebar items. See
|
||||
|
||||
## Toolbar launcher with a local modal
|
||||
|
||||
For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or project.
|
||||
Two toolbar slot types are available depending on where the button should appear:
|
||||
|
||||
- **`globalToolbarButton`** — renders in the top bar on every page, scoped to the company. No entity context. Use for workspace-wide actions.
|
||||
- **`toolbarButton`** — renders on entity detail pages (project, issue, etc.). Receives `entityId` and `entityType`. Declare `entityTypes` to control which pages the button appears on.
|
||||
|
||||
For short-lived actions, mount the appropriate slot type and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or entity.
|
||||
|
||||
Project-scoped example (appears only on project detail pages):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -607,7 +621,8 @@ For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal i
|
||||
"type": "toolbarButton",
|
||||
"id": "sync-toolbar-button",
|
||||
"displayName": "Sync",
|
||||
"exportName": "SyncToolbarButton"
|
||||
"exportName": "SyncToolbarButton",
|
||||
"entityTypes": ["project"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -103,9 +103,10 @@ export interface HostServices {
|
||||
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `events.emit`. */
|
||||
/** Provides `events.emit` and `events.subscribe`. */
|
||||
events: {
|
||||
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
|
||||
subscribe(params: WorkerToHostMethods["events.subscribe"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `http.fetch`. */
|
||||
@@ -261,6 +262,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
|
||||
// Events
|
||||
"events.emit": "events.emit",
|
||||
"events.subscribe": "events.subscribe",
|
||||
|
||||
// HTTP
|
||||
"http.fetch": "http.outbound",
|
||||
@@ -407,6 +409,9 @@ export function createHostClientHandlers(
|
||||
"events.emit": gated("events.emit", async (params) => {
|
||||
return services.events.emit(params);
|
||||
}),
|
||||
"events.subscribe": gated("events.subscribe", async (params) => {
|
||||
return services.events.subscribe(params);
|
||||
}),
|
||||
|
||||
// HTTP
|
||||
"http.fetch": gated("http.fetch", async (params) => {
|
||||
|
||||
@@ -482,6 +482,10 @@ export interface WorkerToHostMethods {
|
||||
params: { name: string; companyId: string; payload: unknown },
|
||||
result: void,
|
||||
];
|
||||
"events.subscribe": [
|
||||
params: { eventPattern: string; filter?: Record<string, unknown> | null },
|
||||
result: void,
|
||||
];
|
||||
|
||||
// HTTP
|
||||
"http.fetch": [
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
* |--- request(initialize) -------------> | → calls plugin.setup(ctx)
|
||||
* |<-- response(ok:true) ---------------- |
|
||||
* | |
|
||||
* |--- request(onEvent) ----------------> | → dispatches to registered handler
|
||||
* |<-- response(void) ------------------ |
|
||||
* |--- notification(onEvent) -----------> | → dispatches to registered handler
|
||||
* | |
|
||||
* |<-- request(state.get) --------------- | ← SDK client call from plugin code
|
||||
* |--- response(result) ----------------> |
|
||||
@@ -387,6 +386,13 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
registration = { name, filter: filterOrFn, fn: maybeFn };
|
||||
}
|
||||
eventHandlers.push(registration);
|
||||
// Register subscription on the host so events are forwarded to this worker
|
||||
void callHost("events.subscribe", { eventPattern: name, filter: registration.filter ?? null }).catch((err) => {
|
||||
notifyHost("log", {
|
||||
level: "warn",
|
||||
message: `Failed to subscribe to event "${name}" on host: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
const idx = eventHandlers.indexOf(registration);
|
||||
if (idx !== -1) eventHandlers.splice(idx, 1);
|
||||
@@ -1107,6 +1113,14 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
const event = notif.params as AgentSessionEvent;
|
||||
const cb = sessionEventCallbacks.get(event.sessionId);
|
||||
if (cb) cb(event);
|
||||
} else if (notif.method === "onEvent" && notif.params) {
|
||||
// Plugin event bus notifications — dispatch to registered event handlers
|
||||
handleOnEvent(notif.params as OnEventParams).catch((err) => {
|
||||
notifyHost("log", {
|
||||
level: "error",
|
||||
message: `Failed to handle event notification: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"openclaw_gateway",
|
||||
"hermes_local",
|
||||
] as const;
|
||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||
|
||||
@@ -370,6 +371,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
|
||||
"sidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"globalToolbarButton",
|
||||
"toolbarButton",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
@@ -419,6 +421,7 @@ export const PLUGIN_LAUNCHER_PLACEMENT_ZONES = [
|
||||
"sidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"globalToolbarButton",
|
||||
"toolbarButton",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface Company {
|
||||
spentMonthlyCents: number;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
brandColor: string | null;
|
||||
logoAssetId: string | null;
|
||||
logoUrl: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { COMPANY_STATUSES } from "../constants.js";
|
||||
|
||||
const logoAssetIdSchema = z.string().uuid().nullable().optional();
|
||||
|
||||
export const createCompanySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional().nullable(),
|
||||
@@ -16,6 +18,7 @@ export const updateCompanySchema = createCompanySchema
|
||||
spentMonthlyCents: z.number().int().nonnegative().optional(),
|
||||
requireBoardApprovalForNewAgents: z.boolean().optional(),
|
||||
brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
||||
logoAssetId: logoAssetIdSchema,
|
||||
});
|
||||
|
||||
export type UpdateCompany = z.infer<typeof updateCompanySchema>;
|
||||
|
||||
@@ -156,6 +156,24 @@ async function maybePreflightMigrations() {
|
||||
|
||||
await maybePreflightMigrations();
|
||||
|
||||
async function buildPluginSdk() {
|
||||
console.log("[paperclip] building plugin sdk...");
|
||||
const result = await runPnpm(
|
||||
["--filter", "@paperclipai/plugin-sdk", "build"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal);
|
||||
return;
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
console.error("[paperclip] plugin sdk build failed");
|
||||
process.exit(result.code);
|
||||
}
|
||||
}
|
||||
|
||||
await buildPluginSdk();
|
||||
|
||||
if (mode === "watch") {
|
||||
env.PAPERCLIP_MIGRATION_PROMPT = "never";
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"hermes-paperclip-adapter": "0.1.1",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
@@ -50,10 +51,12 @@
|
||||
"better-auth": "1.4.18",
|
||||
"chokidar": "^4.0.3",
|
||||
"detect-port": "^2.1.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^17.0.1",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"express": "^5.1.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"open": "^11.0.0",
|
||||
"pino": "^9.6.0",
|
||||
@@ -65,6 +68,7 @@
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/jsdom": "^28.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
250
server/src/__tests__/assets.test.ts
Normal file
250
server/src/__tests__/assets.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { assetRoutes } from "../routes/assets.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
|
||||
const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => ({
|
||||
createAssetMock: vi.fn(),
|
||||
getAssetByIdMock: vi.fn(),
|
||||
logActivityMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
assetService: vi.fn(() => ({
|
||||
create: createAssetMock,
|
||||
getById: getAssetByIdMock,
|
||||
})),
|
||||
logActivity: logActivityMock,
|
||||
}));
|
||||
|
||||
function createAsset() {
|
||||
const now = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
id: "asset-1",
|
||||
companyId: "company-1",
|
||||
provider: "local",
|
||||
objectKey: "assets/abc",
|
||||
contentType: "image/png",
|
||||
byteSize: 40,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: "logo.png",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function createStorageService(contentType = "image/png"): StorageService {
|
||||
const putFile: StorageService["putFile"] = vi.fn(async (input: {
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename: string | null;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}) => {
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`,
|
||||
contentType: contentType || input.contentType,
|
||||
byteSize: input.body.length,
|
||||
sha256: "sha256-sample",
|
||||
originalFilename: input.originalFilename,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
provider: "local_disk" as const,
|
||||
putFile,
|
||||
getObject: vi.fn(),
|
||||
headObject: vi.fn(),
|
||||
deleteObject: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(storage: ReturnType<typeof createStorageService>) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId: "user-1",
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", assetRoutes({} as any, storage));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /api/companies/:companyId/assets/images", () => {
|
||||
afterEach(() => {
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts PNG image uploads and returns an asset path", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "goals")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/goals",
|
||||
originalFilename: "logo.png",
|
||||
contentType: "image/png",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("allows supported non-image attachments outside the company logo flow", async () => {
|
||||
const text = createStorageService("text/plain");
|
||||
const app = createApp(text);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "text/plain",
|
||||
originalFilename: "note.txt",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/assets/images")
|
||||
.field("namespace", "issues/drafts")
|
||||
.attach("file", Buffer.from("hello"), { filename: "note.txt", contentType: "text/plain" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(text.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/issues/drafts",
|
||||
originalFilename: "note.txt",
|
||||
contentType: "text/plain",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/companies/:companyId/logo", () => {
|
||||
afterEach(() => {
|
||||
createAssetMock.mockReset();
|
||||
getAssetByIdMock.mockReset();
|
||||
logActivityMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts PNG logo uploads and returns an asset path", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("png"), "logo.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.contentPath).toBe("/api/assets/asset-1/content");
|
||||
expect(createAssetMock).toHaveBeenCalledTimes(1);
|
||||
expect(png.putFile).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
namespace: "assets/companies",
|
||||
originalFilename: "logo.png",
|
||||
contentType: "image/png",
|
||||
body: expect.any(Buffer),
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes SVG logo uploads before storing them", async () => {
|
||||
const svg = createStorageService("image/svg+xml");
|
||||
const app = createApp(svg);
|
||||
|
||||
createAssetMock.mockResolvedValue({
|
||||
...createAsset(),
|
||||
contentType: "image/svg+xml",
|
||||
originalFilename: "logo.svg",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach(
|
||||
"file",
|
||||
Buffer.from(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' onload='alert(1)'><script>alert(1)</script><a href='https://evil.example/'><circle cx='12' cy='12' r='10'/></a></svg>",
|
||||
),
|
||||
"logo.svg",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(svg.putFile).toHaveBeenCalledTimes(1);
|
||||
const stored = (svg.putFile as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
|
||||
expect(stored.contentType).toBe("image/svg+xml");
|
||||
expect(stored.originalFilename).toBe("logo.svg");
|
||||
const body = stored.body.toString("utf8");
|
||||
expect(body).toContain("<svg");
|
||||
expect(body).toContain("<circle");
|
||||
expect(body).not.toContain("<script");
|
||||
expect(body).not.toContain("onload=");
|
||||
expect(body).not.toContain("https://evil.example/");
|
||||
});
|
||||
|
||||
it("allows logo uploads within the general attachment limit", async () => {
|
||||
const png = createStorageService("image/png");
|
||||
const app = createApp(png);
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(150 * 1024, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "within-limit.png");
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects logo files larger than the general attachment limit", async () => {
|
||||
const app = createApp(createStorageService());
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const file = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, "a");
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", file, "too-large.png");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe(`Image exceeds ${MAX_ATTACHMENT_BYTES} bytes`);
|
||||
});
|
||||
|
||||
it("rejects unsupported image types", async () => {
|
||||
const app = createApp(createStorageService("text/plain"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not an image"), "note.txt");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Unsupported image type: text/plain");
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SVG image uploads that cannot be sanitized", async () => {
|
||||
const app = createApp(createStorageService("image/svg+xml"));
|
||||
createAssetMock.mockResolvedValue(createAsset());
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/logo")
|
||||
.attach("file", Buffer.from("not actually svg"), "logo.svg");
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("SVG could not be sanitized");
|
||||
expect(createAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,15 @@ import {
|
||||
import {
|
||||
agentConfigurationDoc as piAgentConfigurationDoc,
|
||||
} from "@paperclipai/adapter-pi-local";
|
||||
import {
|
||||
execute as hermesExecute,
|
||||
testEnvironment as hermesTestEnvironment,
|
||||
sessionCodec as hermesSessionCodec,
|
||||
} from "hermes-paperclip-adapter/server";
|
||||
import {
|
||||
agentConfigurationDoc as hermesAgentConfigurationDoc,
|
||||
models as hermesModels,
|
||||
} from "hermes-paperclip-adapter";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
|
||||
@@ -127,6 +136,16 @@ const piLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: piAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const hermesLocalAdapter: ServerAdapterModule = {
|
||||
type: "hermes_local",
|
||||
execute: hermesExecute,
|
||||
testEnvironment: hermesTestEnvironment,
|
||||
sessionCodec: hermesSessionCodec,
|
||||
models: hermesModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: hermesAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[
|
||||
claudeLocalAdapter,
|
||||
@@ -136,6 +155,7 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
cursorLocalAdapter,
|
||||
geminiLocalAdapter,
|
||||
openclawGatewayAdapter,
|
||||
hermesLocalAdapter,
|
||||
processAdapter,
|
||||
httpAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
|
||||
@@ -38,6 +38,7 @@ import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
|
||||
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
|
||||
import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js";
|
||||
import { createPluginEventBus } from "./services/plugin-event-bus.js";
|
||||
import { setPluginEventBus } from "./services/activity-log.js";
|
||||
import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js";
|
||||
import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js";
|
||||
import { pluginRegistryService } from "./services/plugin-registry.js";
|
||||
@@ -150,6 +151,7 @@ export async function createApp(
|
||||
const workerManager = createPluginWorkerManager();
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
const eventBus = createPluginEventBus();
|
||||
setPluginEventBus(eventBus);
|
||||
const jobStore = pluginJobStore(db);
|
||||
const lifecycle = pluginLifecycleManager(db, { workerManager });
|
||||
const scheduler = createPluginJobScheduler({
|
||||
@@ -249,7 +251,7 @@ export async function createApp(
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const vite = await createViteServer({
|
||||
root: uiRoot,
|
||||
appType: "spa",
|
||||
appType: "custom",
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readConfigFile } from "./config-file.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { resolvePaperclipEnvPath } from "./paths.js";
|
||||
import {
|
||||
@@ -27,6 +28,14 @@ if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
|
||||
loadDotenv({ path: PAPERCLIP_ENV_FILE_PATH, override: false, quiet: true });
|
||||
}
|
||||
|
||||
const CWD_ENV_PATH = resolve(process.cwd(), ".env");
|
||||
const isSameFile = existsSync(CWD_ENV_PATH) && existsSync(PAPERCLIP_ENV_FILE_PATH)
|
||||
? realpathSync(CWD_ENV_PATH) === realpathSync(PAPERCLIP_ENV_FILE_PATH)
|
||||
: CWD_ENV_PATH === PAPERCLIP_ENV_FILE_PATH;
|
||||
if (!isSameFile && existsSync(CWD_ENV_PATH)) {
|
||||
loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true });
|
||||
}
|
||||
|
||||
type DatabaseMode = "embedded-postgres" | "postgres";
|
||||
|
||||
export interface Config {
|
||||
|
||||
@@ -1,21 +1,104 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assetService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
const SVG_CONTENT_TYPE = "image/svg+xml";
|
||||
const ALLOWED_COMPANY_LOGO_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
SVG_CONTENT_TYPE,
|
||||
]);
|
||||
|
||||
function sanitizeSvgBuffer(input: Buffer): Buffer | null {
|
||||
const raw = input.toString("utf8").trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const baseDom = new JSDOM("");
|
||||
const domPurify = createDOMPurify(
|
||||
baseDom.window as unknown as Parameters<typeof createDOMPurify>[0],
|
||||
);
|
||||
domPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
||||
const attrName = data.attrName.toLowerCase();
|
||||
const attrValue = (data.attrValue ?? "").trim();
|
||||
|
||||
if (attrName.startsWith("on")) {
|
||||
data.keepAttr = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) {
|
||||
data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
|
||||
let parsedDom: JSDOM | null = null;
|
||||
try {
|
||||
const sanitized = domPurify.sanitize(raw, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true, html: false },
|
||||
FORBID_TAGS: ["script", "foreignObject"],
|
||||
FORBID_CONTENTS: ["script", "foreignObject"],
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
});
|
||||
|
||||
parsedDom = new JSDOM(sanitized, { contentType: SVG_CONTENT_TYPE });
|
||||
const document = parsedDom.window.document;
|
||||
const root = document.documentElement;
|
||||
if (!root || root.tagName.toLowerCase() !== "svg") return null;
|
||||
|
||||
for (const el of Array.from(root.querySelectorAll("script, foreignObject"))) {
|
||||
el.remove();
|
||||
}
|
||||
for (const el of Array.from(root.querySelectorAll("*"))) {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const attrName = attr.name.toLowerCase();
|
||||
const attrValue = attr.value.trim();
|
||||
if (attrName.startsWith("on")) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = root.outerHTML.trim();
|
||||
if (!output || !/^<svg[\s>]/i.test(output)) return null;
|
||||
return Buffer.from(output, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
parsedDom?.window.close();
|
||||
baseDom.window.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function assetRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
const svc = assetService(db);
|
||||
const upload = multer({
|
||||
const assetUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
const companyLogoUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
async function runSingleFileUpload(
|
||||
upload: ReturnType<typeof multer>,
|
||||
req: Request,
|
||||
res: Response,
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
@@ -29,7 +112,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res);
|
||||
await runSingleFileUpload(assetUpload, req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
@@ -48,16 +131,6 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
if (file.buffer.length <= 0) {
|
||||
res.status(422).json({ error: "Image is empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
||||
if (!parsedMeta.success) {
|
||||
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
|
||||
@@ -65,13 +138,32 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
|
||||
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
let fileBody = file.buffer;
|
||||
if (contentType === SVG_CONTENT_TYPE) {
|
||||
const sanitized = sanitizeSvgBuffer(file.buffer);
|
||||
if (!sanitized || sanitized.length <= 0) {
|
||||
res.status(422).json({ error: "SVG could not be sanitized" });
|
||||
return;
|
||||
}
|
||||
fileBody = sanitized;
|
||||
}
|
||||
if (fileBody.length <= 0) {
|
||||
res.status(422).json({ error: "Image is empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const stored = await storage.putFile({
|
||||
companyId,
|
||||
namespace: `assets/${namespaceSuffix}`,
|
||||
originalFilename: file.originalname || null,
|
||||
contentType,
|
||||
body: file.buffer,
|
||||
body: fileBody,
|
||||
});
|
||||
|
||||
const asset = await svc.create(companyId, {
|
||||
@@ -118,6 +210,105 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/logo", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(companyLogoUpload, req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Image exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
|
||||
if (!file) {
|
||||
res.status(400).json({ error: "Missing file field 'file'" });
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!ALLOWED_COMPANY_LOGO_CONTENT_TYPES.has(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
|
||||
let fileBody = file.buffer;
|
||||
if (contentType === SVG_CONTENT_TYPE) {
|
||||
const sanitized = sanitizeSvgBuffer(file.buffer);
|
||||
if (!sanitized || sanitized.length <= 0) {
|
||||
res.status(422).json({ error: "SVG could not be sanitized" });
|
||||
return;
|
||||
}
|
||||
fileBody = sanitized;
|
||||
}
|
||||
|
||||
if (fileBody.length <= 0) {
|
||||
res.status(422).json({ error: "Image is empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const stored = await storage.putFile({
|
||||
companyId,
|
||||
namespace: "assets/companies",
|
||||
originalFilename: file.originalname || null,
|
||||
contentType,
|
||||
body: fileBody,
|
||||
});
|
||||
|
||||
const asset = await svc.create(companyId, {
|
||||
provider: stored.provider,
|
||||
objectKey: stored.objectKey,
|
||||
contentType: stored.contentType,
|
||||
byteSize: stored.byteSize,
|
||||
sha256: stored.sha256,
|
||||
originalFilename: stored.originalFilename,
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "asset.created",
|
||||
entityType: "asset",
|
||||
entityId: asset.id,
|
||||
details: {
|
||||
originalFilename: asset.originalFilename,
|
||||
contentType: asset.contentType,
|
||||
byteSize: asset.byteSize,
|
||||
namespace: "assets/companies",
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
assetId: asset.id,
|
||||
companyId: asset.companyId,
|
||||
provider: asset.provider,
|
||||
objectKey: asset.objectKey,
|
||||
contentType: asset.contentType,
|
||||
byteSize: asset.byteSize,
|
||||
sha256: asset.sha256,
|
||||
originalFilename: asset.originalFilename,
|
||||
createdByAgentId: asset.createdByAgentId,
|
||||
createdByUserId: asset.createdByUserId,
|
||||
createdAt: asset.createdAt,
|
||||
updatedAt: asset.updatedAt,
|
||||
contentPath: `/api/assets/${asset.id}/content`,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/assets/:assetId/content", async (req, res, next) => {
|
||||
const assetId = req.params.assetId as string;
|
||||
const asset = await svc.getById(assetId);
|
||||
@@ -128,9 +319,14 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
assertCompanyAccess(req, asset.companyId);
|
||||
|
||||
const object = await storage.getObject(asset.companyId, asset.objectKey);
|
||||
res.setHeader("Content-Type", asset.contentType || object.contentType || "application/octet-stream");
|
||||
const responseContentType = asset.contentType || object.contentType || "application/octet-stream";
|
||||
res.setHeader("Content-Type", responseContentType);
|
||||
res.setHeader("Content-Length", String(asset.byteSize || object.contentLength || 0));
|
||||
res.setHeader("Cache-Control", "private, max-age=60");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (responseContentType === SVG_CONTENT_TYPE) {
|
||||
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||
}
|
||||
const filename = asset.originalFilename ?? "asset";
|
||||
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||
|
||||
@@ -142,4 +338,3 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog } from "@paperclipai/db";
|
||||
import { PLUGIN_EVENT_TYPES, type PluginEventType } from "@paperclipai/shared";
|
||||
import type { PluginEvent } from "@paperclipai/plugin-sdk";
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { sanitizeRecord } from "../redaction.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import type { PluginEventBus } from "./plugin-event-bus.js";
|
||||
|
||||
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
|
||||
|
||||
let _pluginEventBus: PluginEventBus | null = null;
|
||||
|
||||
/** Wire the plugin event bus so domain events are forwarded to plugins. */
|
||||
export function setPluginEventBus(bus: PluginEventBus): void {
|
||||
if (_pluginEventBus) {
|
||||
logger.warn("setPluginEventBus called more than once, replacing existing bus");
|
||||
}
|
||||
_pluginEventBus = bus;
|
||||
}
|
||||
|
||||
export interface LogActivityInput {
|
||||
companyId: string;
|
||||
@@ -45,4 +62,27 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
details: redactedDetails,
|
||||
},
|
||||
});
|
||||
|
||||
if (_pluginEventBus && PLUGIN_EVENT_SET.has(input.action)) {
|
||||
const event: PluginEvent = {
|
||||
eventId: randomUUID(),
|
||||
eventType: input.action as PluginEventType,
|
||||
occurredAt: new Date().toISOString(),
|
||||
actorId: input.actorId,
|
||||
actorType: input.actorType,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
companyId: input.companyId,
|
||||
payload: {
|
||||
...redactedDetails,
|
||||
agentId: input.agentId ?? null,
|
||||
runId: input.runId ?? null,
|
||||
},
|
||||
};
|
||||
void _pluginEventBus.emit(event).then(({ errors }) => {
|
||||
for (const { pluginId, error } of errors) {
|
||||
logger.warn({ pluginId, eventType: event.eventType, err: error }, "plugin event handler failed");
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { eq, count } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companies,
|
||||
companyLogos,
|
||||
assets,
|
||||
agents,
|
||||
agentApiKeys,
|
||||
agentRuntimeState,
|
||||
@@ -23,10 +25,41 @@ import {
|
||||
principalPermissionGrants,
|
||||
companyMemberships,
|
||||
} from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export function companyService(db: Db) {
|
||||
const ISSUE_PREFIX_FALLBACK = "CMP";
|
||||
|
||||
const companySelection = {
|
||||
id: companies.id,
|
||||
name: companies.name,
|
||||
description: companies.description,
|
||||
status: companies.status,
|
||||
issuePrefix: companies.issuePrefix,
|
||||
issueCounter: companies.issueCounter,
|
||||
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||
spentMonthlyCents: companies.spentMonthlyCents,
|
||||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||
brandColor: companies.brandColor,
|
||||
logoAssetId: companyLogos.assetId,
|
||||
createdAt: companies.createdAt,
|
||||
updatedAt: companies.updatedAt,
|
||||
};
|
||||
|
||||
function enrichCompany<T extends { logoAssetId: string | null }>(company: T) {
|
||||
return {
|
||||
...company,
|
||||
logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCompanyQuery(database: Pick<Db, "select">) {
|
||||
return database
|
||||
.select(companySelection)
|
||||
.from(companies)
|
||||
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id));
|
||||
}
|
||||
|
||||
function deriveIssuePrefixBase(name: string) {
|
||||
const normalized = name.toUpperCase().replace(/[^A-Z]/g, "");
|
||||
return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK;
|
||||
@@ -70,32 +103,97 @@ export function companyService(db: Db) {
|
||||
}
|
||||
|
||||
return {
|
||||
list: () => db.select().from(companies),
|
||||
list: () =>
|
||||
getCompanyQuery(db).then((rows) => rows.map((row) => enrichCompany(row))),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(companies)
|
||||
getCompanyQuery(db)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.then((rows) => (rows[0] ? enrichCompany(rows[0]) : null)),
|
||||
|
||||
create: async (data: typeof companies.$inferInsert) => createCompanyWithUniquePrefix(data),
|
||||
create: async (data: typeof companies.$inferInsert) => {
|
||||
const created = await createCompanyWithUniquePrefix(data);
|
||||
const row = await getCompanyQuery(db)
|
||||
.where(eq(companies.id, created.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Company not found after creation");
|
||||
return enrichCompany(row);
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
|
||||
db
|
||||
.update(companies)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
update: (
|
||||
id: string,
|
||||
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
|
||||
) =>
|
||||
db.transaction(async (tx) => {
|
||||
const existing = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const { logoAssetId, ...companyPatch } = data;
|
||||
|
||||
if (logoAssetId !== undefined && logoAssetId !== null) {
|
||||
const nextLogoAsset = await tx
|
||||
.select({ id: assets.id, companyId: assets.companyId })
|
||||
.from(assets)
|
||||
.where(eq(assets.id, logoAssetId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!nextLogoAsset) throw notFound("Logo asset not found");
|
||||
if (nextLogoAsset.companyId !== existing.id) {
|
||||
throw unprocessable("Logo asset must belong to the same company");
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ ...companyPatch, updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
|
||||
if (logoAssetId === null) {
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
} else if (logoAssetId !== undefined) {
|
||||
await tx
|
||||
.insert(companyLogos)
|
||||
.values({
|
||||
companyId: id,
|
||||
assetId: logoAssetId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: companyLogos.companyId,
|
||||
set: {
|
||||
assetId: logoAssetId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (logoAssetId !== undefined && existing.logoAssetId && existing.logoAssetId !== logoAssetId) {
|
||||
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
|
||||
}
|
||||
|
||||
return enrichCompany({
|
||||
...updated,
|
||||
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
||||
});
|
||||
}),
|
||||
|
||||
archive: (id: string) =>
|
||||
db
|
||||
.update(companies)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
const row = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? enrichCompany(row) : null;
|
||||
}),
|
||||
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
@@ -116,6 +214,8 @@ export function companyService(db: Db) {
|
||||
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
||||
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
||||
await tx.delete(issues).where(eq(issues.companyId, id));
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
await tx.delete(assets).where(eq(assets.companyId, id));
|
||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||
await tx.delete(agents).where(eq(agents.companyId, id));
|
||||
|
||||
@@ -1364,11 +1364,11 @@ export function heartbeatService(db: Db) {
|
||||
const staleThresholdMs = opts?.staleThresholdMs ?? 0;
|
||||
const now = new Date();
|
||||
|
||||
// Find all runs in "queued" or "running" state
|
||||
// Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them)
|
||||
const activeRuns = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(inArray(heartbeatRuns.status, ["queued", "running"]));
|
||||
.where(eq(heartbeatRuns.status, "running"));
|
||||
|
||||
const reaped: string[] = [];
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||||
detailTab: "ui.detailTab.register",
|
||||
taskDetailView: "ui.detailTab.register",
|
||||
dashboardWidget: "ui.dashboardWidget.register",
|
||||
globalToolbarButton: "ui.action.register",
|
||||
toolbarButton: "ui.action.register",
|
||||
contextMenuItem: "ui.action.register",
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
@@ -124,6 +125,7 @@ const LAUNCHER_PLACEMENT_CAPABILITIES: Record<
|
||||
sidebar: "ui.sidebar.register",
|
||||
sidebarPanel: "ui.sidebar.register",
|
||||
projectSidebarItem: "ui.sidebar.register",
|
||||
globalToolbarButton: "ui.action.register",
|
||||
toolbarButton: "ui.action.register",
|
||||
contextMenuItem: "ui.action.register",
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
|
||||
@@ -34,6 +34,10 @@ export function validateInstanceConfig(
|
||||
// ajv-formats v3 default export is a FormatsPlugin object; call it as a plugin.
|
||||
const applyFormats = (addFormats as any).default ?? addFormats;
|
||||
applyFormats(ajv);
|
||||
// Register the secret-ref format used by plugin manifests to mark fields that
|
||||
// hold a Paperclip secret UUID rather than a raw value. The format is a UI
|
||||
// hint only — UUID validation happens in the secrets handler at resolve time.
|
||||
ajv.addFormat("secret-ref", { validate: () => true });
|
||||
const validate = ajv.compile(schema);
|
||||
const valid = validate(configJson);
|
||||
|
||||
|
||||
@@ -556,6 +556,18 @@ export function buildHostServices(
|
||||
}
|
||||
await scopedBus.emit(params.name, params.companyId, params.payload);
|
||||
},
|
||||
async subscribe(params: { eventPattern: string; filter?: Record<string, unknown> | null }) {
|
||||
const handler = async (event: import("@paperclipai/plugin-sdk").PluginEvent) => {
|
||||
if (notifyWorker) {
|
||||
notifyWorker("onEvent", { event });
|
||||
}
|
||||
};
|
||||
if (params.filter) {
|
||||
scopedBus.subscribe(params.eventPattern as any, params.filter as any, handler);
|
||||
} else {
|
||||
scopedBus.subscribe(params.eventPattern as any, handler);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
http: {
|
||||
@@ -1058,6 +1070,10 @@ export function buildHostServices(
|
||||
dispose() {
|
||||
disposed = true;
|
||||
|
||||
// Clear event bus subscriptions to prevent accumulation on worker restart.
|
||||
// Without this, each crash/restart cycle adds duplicate subscriptions.
|
||||
scopedBus.clear();
|
||||
|
||||
// Snapshot to avoid iterator invalidation from concurrent sendMessage() calls
|
||||
const snapshot = Array.from(activeSubscriptions);
|
||||
activeSubscriptions.clear();
|
||||
|
||||
@@ -1302,6 +1302,7 @@ export function pluginLoader(
|
||||
const plugin = (await registry.getById(pluginId)) as {
|
||||
id: string;
|
||||
packageName: string;
|
||||
packagePath: string | null;
|
||||
manifestJson: PaperclipPluginManifestV1;
|
||||
} | null;
|
||||
if (!plugin) throw new Error(`Plugin not found: ${pluginId}`);
|
||||
@@ -1309,7 +1310,10 @@ export function pluginLoader(
|
||||
const oldManifest = plugin.manifestJson;
|
||||
const {
|
||||
packageName = plugin.packageName,
|
||||
localPath,
|
||||
// For local-path installs, fall back to the stored packagePath so
|
||||
// `upgradePlugin` can re-read the manifest from disk without needing
|
||||
// the caller to re-supply the path every time.
|
||||
localPath = plugin.packagePath ?? undefined,
|
||||
version,
|
||||
} = upgradeOptions;
|
||||
|
||||
|
||||
@@ -11,11 +11,19 @@ export const assetsApi = {
|
||||
const safeFile = new File([buffer], file.name, { type: file.type });
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", safeFile);
|
||||
if (namespace && namespace.trim().length > 0) {
|
||||
form.append("namespace", namespace.trim());
|
||||
}
|
||||
form.append("file", safeFile);
|
||||
return api.postForm<AssetImage>(`/companies/${companyId}/assets/images`, form);
|
||||
},
|
||||
};
|
||||
|
||||
uploadCompanyLogo: async (companyId: string, file: File) => {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const safeFile = new File([buffer], file.name, { type: file.type });
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", safeFile);
|
||||
return api.postForm<AssetImage>(`/companies/${companyId}/logo`, form);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,14 +14,18 @@ export const companiesApi = {
|
||||
list: () => api.get<Company[]>("/companies"),
|
||||
get: (companyId: string) => api.get<Company>(`/companies/${companyId}`),
|
||||
stats: () => api.get<CompanyStats>("/companies/stats"),
|
||||
create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
|
||||
create: (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) =>
|
||||
api.post<Company>("/companies", data),
|
||||
update: (
|
||||
companyId: string,
|
||||
data: Partial<
|
||||
Pick<
|
||||
Company,
|
||||
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor"
|
||||
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoAssetId"
|
||||
>
|
||||
>,
|
||||
) => api.patch<Company>(`/companies/${companyId}`, data),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link } from "@/lib/router";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -11,13 +12,46 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers";
|
||||
|
||||
type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null };
|
||||
|
||||
function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
|
||||
const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId });
|
||||
const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId });
|
||||
if (slots.length === 0 && launchers.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 ml-auto shrink-0 pl-2">
|
||||
<PluginSlotOutlet slotTypes={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
|
||||
<PluginLauncherOutlet placementZones={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
const { breadcrumbs } = useBreadcrumbs();
|
||||
const { toggleSidebar, isMobile } = useSidebar();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
|
||||
if (breadcrumbs.length === 0) return null;
|
||||
const globalToolbarSlotContext = useMemo(
|
||||
() => ({
|
||||
companyId: selectedCompanyId ?? null,
|
||||
companyPrefix: selectedCompany?.issuePrefix ?? null,
|
||||
}),
|
||||
[selectedCompanyId, selectedCompany?.issuePrefix],
|
||||
);
|
||||
|
||||
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const menuButton = isMobile && (
|
||||
<Button
|
||||
@@ -34,40 +68,46 @@ export function BreadcrumbBar() {
|
||||
// Single breadcrumb = page title (uppercase)
|
||||
if (breadcrumbs.length === 1) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center min-w-0 overflow-hidden">
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
||||
{breadcrumbs[0].label}
|
||||
</h1>
|
||||
<div className="min-w-0 overflow-hidden flex-1">
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
||||
{breadcrumbs[0].label}
|
||||
</h1>
|
||||
</div>
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple breadcrumbs = breadcrumb trail
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center min-w-0 overflow-hidden">
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
|
||||
{isLast || !crumb.href ? (
|
||||
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={crumb.href}>{crumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="min-w-0 overflow-hidden flex-1">
|
||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
|
||||
{isLast || !crumb.href ? (
|
||||
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={crumb.href}>{crumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,11 +75,15 @@ export function CommandPalette() {
|
||||
enabled: !!selectedCompanyId && open,
|
||||
});
|
||||
|
||||
const { data: projects = [] } = useQuery({
|
||||
const { data: allProjects = [] } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && open,
|
||||
});
|
||||
const projects = useMemo(
|
||||
() => allProjects.filter((p) => !p.archivedAt),
|
||||
[allProjects],
|
||||
);
|
||||
|
||||
function go(path: string) {
|
||||
setOpen(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const BAYER_4X4 = [
|
||||
@@ -10,6 +10,7 @@ const BAYER_4X4 = [
|
||||
|
||||
interface CompanyPatternIconProps {
|
||||
companyName: string;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
@@ -159,8 +160,18 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
export function CompanyPatternIcon({ companyName, brandColor, className }: CompanyPatternIconProps) {
|
||||
export function CompanyPatternIcon({
|
||||
companyName,
|
||||
logoUrl,
|
||||
brandColor,
|
||||
className,
|
||||
}: CompanyPatternIconProps) {
|
||||
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const logo = !imageError && typeof logoUrl === "string" && logoUrl.trim().length > 0 ? logoUrl : null;
|
||||
useEffect(() => {
|
||||
setImageError(false);
|
||||
}, [logoUrl]);
|
||||
const patternDataUrl = useMemo(
|
||||
() => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor),
|
||||
[companyName, brandColor],
|
||||
@@ -173,7 +184,14 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{patternDataUrl ? (
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={`${companyName} logo`}
|
||||
onError={() => setImageError(true)}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : patternDataUrl ? (
|
||||
<img
|
||||
src={patternDataUrl}
|
||||
alt=""
|
||||
@@ -184,9 +202,11 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
)}
|
||||
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
|
||||
{initial}
|
||||
</span>
|
||||
{!logo && (
|
||||
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ function SortableCompanyItem({
|
||||
>
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
brandColor={company.brandColor}
|
||||
className={cn(
|
||||
isSelected
|
||||
|
||||
@@ -163,8 +163,12 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
queryFn: () => projectsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const activeProjects = useMemo(
|
||||
() => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId),
|
||||
[projects, issue.projectId],
|
||||
);
|
||||
const { orderedProjects } = useProjectOrder({
|
||||
projects: projects ?? [],
|
||||
projects: activeProjects,
|
||||
companyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden prose-pre:whitespace-pre-wrap prose-pre:break-words prose-code:break-all",
|
||||
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
|
||||
theme === "dark" && "prose-invert",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -346,8 +346,12 @@ export function NewIssueDialog() {
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const activeProjects = useMemo(
|
||||
() => (projects ?? []).filter((p) => !p.archivedAt),
|
||||
[projects],
|
||||
);
|
||||
const { orderedProjects } = useProjectOrder({
|
||||
projects: projects ?? [],
|
||||
projects: activeProjects,
|
||||
companyId: effectiveCompanyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { DraftInput } from "./agent-config-primitives";
|
||||
import { InlineEditor } from "./InlineEditor";
|
||||
@@ -32,6 +32,8 @@ interface ProjectPropertiesProps {
|
||||
onUpdate?: (data: Record<string, unknown>) => void;
|
||||
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
|
||||
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
|
||||
onArchive?: (archived: boolean) => void;
|
||||
archivePending?: boolean;
|
||||
}
|
||||
|
||||
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
|
||||
@@ -148,7 +150,7 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) {
|
||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { enabled: showExperimentalWorkspaceUi } = useExperimentalWorkspacesEnabled();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -975,6 +977,45 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{onArchive && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
||||
Danger Zone
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.archivedAt
|
||||
? "Unarchive this project to restore it in the sidebar and project selectors."
|
||||
: "Archive this project to hide it from the sidebar and project selectors."}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archivePending}
|
||||
onClick={() => {
|
||||
const action = project.archivedAt ? "Unarchive" : "Archive";
|
||||
const confirmed = window.confirm(
|
||||
`${action} project "${project.name}"?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
onArchive(!project.archivedAt);
|
||||
}}
|
||||
>
|
||||
{archivePending ? (
|
||||
<><Loader2 className="h-3 w-3 animate-spin mr-1" />{project.archivedAt ? "Unarchiving..." : "Archiving..."}</>
|
||||
) : project.archivedAt ? (
|
||||
<><ArchiveRestore className="h-3 w-3 mr-1" />Unarchive project</>
|
||||
) : (
|
||||
<><Archive className="h-3 w-3 mr-1" />Archive project</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,11 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
}, [queryClient]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) =>
|
||||
companiesApi.create(data),
|
||||
onSuccess: (company) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
@@ -94,7 +98,11 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
const createCompany = useCallback(
|
||||
async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => {
|
||||
async (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) => {
|
||||
return createMutation.mutateAsync(data);
|
||||
},
|
||||
[createMutation],
|
||||
|
||||
125
ui/src/index.css
125
ui/src/index.css
@@ -178,9 +178,16 @@
|
||||
background: oklch(0.5 0 0);
|
||||
}
|
||||
|
||||
/* Auto-hide scrollbar: transparent by default, visible on container hover */
|
||||
/* Auto-hide scrollbar: fully invisible by default, visible on container hover */
|
||||
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
||||
background: transparent !important;
|
||||
}
|
||||
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
||||
background: transparent !important;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||
background: oklch(0.205 0 0) !important;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0 0) !important;
|
||||
@@ -405,30 +412,118 @@
|
||||
|
||||
.paperclip-mdxeditor-content code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.84em;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content pre {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
margin: 0.4rem 0;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: color-mix(in oklab, var(--accent) 50%, transparent);
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
|
||||
Matches the editor theme so rendered code looks consistent. */
|
||||
.prose pre {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background-color: color-mix(in oklab, var(--accent) 50%, transparent);
|
||||
color: var(--foreground);
|
||||
/* Dark theme for CodeMirror code blocks inside the MDXEditor.
|
||||
Overrides the default cm6-theme-basic-light that MDXEditor bundles. */
|
||||
.paperclip-mdxeditor .cm-editor {
|
||||
background-color: #1e1e2e !important;
|
||||
color: #cdd6f4 !important;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
.paperclip-mdxeditor .cm-gutters {
|
||||
background-color: #181825 !important;
|
||||
color: #585b70 !important;
|
||||
border-right: 1px solid #313244 !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-activeLineGutter {
|
||||
background-color: #1e1e2e !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-activeLine {
|
||||
background-color: color-mix(in oklab, #cdd6f4 5%, transparent) !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-cursor,
|
||||
.paperclip-mdxeditor .cm-dropCursor {
|
||||
border-left-color: #cdd6f4 !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-selectionBackground {
|
||||
background-color: color-mix(in oklab, #89b4fa 25%, transparent) !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-focused .cm-selectionBackground {
|
||||
background-color: color-mix(in oklab, #89b4fa 30%, transparent) !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-content {
|
||||
caret-color: #cdd6f4;
|
||||
}
|
||||
|
||||
/* MDXEditor code block language selector – show on hover only */
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorToolbar_"],
|
||||
.paperclip-mdxeditor-content [class*="_codeBlockToolbar_"] {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorToolbar_"] select,
|
||||
.paperclip-mdxeditor-content [class*="_codeBlockToolbar_"] select {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border-color: #45475a;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:hover [class*="_codeMirrorToolbar_"],
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:hover [class*="_codeBlockToolbar_"],
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeMirrorToolbar_"],
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeBlockToolbar_"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
|
||||
Dark theme code blocks with compact sizing.
|
||||
Override prose CSS variables so prose-invert can't revert to defaults. */
|
||||
.paperclip-markdown {
|
||||
--tw-prose-pre-bg: #1e1e2e;
|
||||
--tw-prose-pre-code: #cdd6f4;
|
||||
--tw-prose-invert-pre-bg: #1e1e2e;
|
||||
--tw-prose-invert-pre-code: #cdd6f4;
|
||||
}
|
||||
|
||||
.paperclip-markdown pre {
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent) !important;
|
||||
border-radius: calc(var(--radius) - 3px) !important;
|
||||
background-color: #1e1e2e !important;
|
||||
color: #cdd6f4 !important;
|
||||
padding: 0.5rem 0.65rem !important;
|
||||
margin: 0.4rem 0 !important;
|
||||
font-size: 0.78em !important;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.paperclip-markdown code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.84em;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.paperclip-markdown pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Remove backtick pseudo-elements from inline code (prose default adds them) */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { accessApi } from "../api/access";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, Check } from "lucide-react";
|
||||
@@ -34,6 +35,8 @@ export function CompanySettings() {
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [brandColor, setBrandColor] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
|
||||
// Sync local state from selected company
|
||||
useEffect(() => {
|
||||
@@ -41,6 +44,7 @@ export function CompanySettings() {
|
||||
setCompanyName(selectedCompany.name);
|
||||
setDescription(selectedCompany.description ?? "");
|
||||
setBrandColor(selectedCompany.brandColor ?? "");
|
||||
setLogoUrl(selectedCompany.logoUrl ?? "");
|
||||
}, [selectedCompany]);
|
||||
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
@@ -128,6 +132,42 @@ export function CompanySettings() {
|
||||
}
|
||||
});
|
||||
|
||||
const syncLogoState = (nextLogoUrl: string | null) => {
|
||||
setLogoUrl(nextLogoUrl ?? "");
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
};
|
||||
|
||||
const logoUploadMutation = useMutation({
|
||||
mutationFn: (file: File) =>
|
||||
assetsApi
|
||||
.uploadCompanyLogo(selectedCompanyId!, file)
|
||||
.then((asset) => companiesApi.update(selectedCompanyId!, { logoAssetId: asset.assetId })),
|
||||
onSuccess: (company) => {
|
||||
syncLogoState(company.logoUrl);
|
||||
setLogoUploadError(null);
|
||||
}
|
||||
});
|
||||
|
||||
const clearLogoMutation = useMutation({
|
||||
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoAssetId: null }),
|
||||
onSuccess: (company) => {
|
||||
setLogoUploadError(null);
|
||||
syncLogoState(company.logoUrl);
|
||||
}
|
||||
});
|
||||
|
||||
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.currentTarget.value = "";
|
||||
if (!file) return;
|
||||
setLogoUploadError(null);
|
||||
logoUploadMutation.mutate(file);
|
||||
}
|
||||
|
||||
function handleClearLogo() {
|
||||
clearLogoMutation.mutate();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setInviteError(null);
|
||||
setInviteSnippet(null);
|
||||
@@ -224,11 +264,53 @@ export function CompanySettings() {
|
||||
<div className="shrink-0">
|
||||
<CompanyPatternIcon
|
||||
companyName={companyName || selectedCompany.name}
|
||||
logoUrl={logoUrl || null}
|
||||
brandColor={brandColor || null}
|
||||
className="rounded-[14px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex-1 space-y-3">
|
||||
<Field
|
||||
label="Logo"
|
||||
hint="Upload a PNG, JPEG, WEBP, GIF, or SVG logo image."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||||
onChange={handleLogoFileChange}
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none file:mr-4 file:rounded-md file:border-0 file:bg-muted file:px-2.5 file:py-1 file:text-xs"
|
||||
/>
|
||||
{logoUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleClearLogo}
|
||||
disabled={clearLogoMutation.isPending}
|
||||
>
|
||||
{clearLogoMutation.isPending ? "Removing..." : "Remove logo"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(logoUploadMutation.isError || logoUploadError) && (
|
||||
<span className="text-xs text-destructive">
|
||||
{logoUploadError ??
|
||||
(logoUploadMutation.error instanceof Error
|
||||
? logoUploadMutation.error.message
|
||||
: "Logo upload failed")}
|
||||
</span>
|
||||
)}
|
||||
{clearLogoMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{clearLogoMutation.error.message}
|
||||
</span>
|
||||
)}
|
||||
{logoUploadMutation.isPending && (
|
||||
<span className="text-xs text-muted-foreground">Uploading logo...</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="Brand color"
|
||||
hint="Sets the hue for the company icon. Leave empty for auto-generated color."
|
||||
@@ -285,8 +367,8 @@ export function CompanySettings() {
|
||||
{generalMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{generalMutation.error instanceof Error
|
||||
? generalMutation.error.message
|
||||
: "Failed to save"}
|
||||
? generalMutation.error.message
|
||||
: "Failed to save"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -269,7 +269,7 @@ export function OrgChart() {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-[calc(100vh-4rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
className="w-full h-[calc(100dvh-6rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
||||
@@ -274,6 +274,21 @@ export function ProjectDetail() {
|
||||
onSuccess: invalidateProject,
|
||||
});
|
||||
|
||||
const archiveProject = useMutation({
|
||||
mutationFn: (archived: boolean) =>
|
||||
projectsApi.update(
|
||||
projectLookupRef,
|
||||
{ archivedAt: archived ? new Date().toISOString() : null },
|
||||
resolvedCompanyId ?? lookupCompanyId,
|
||||
),
|
||||
onSuccess: (_, archived) => {
|
||||
invalidateProject();
|
||||
if (archived) {
|
||||
navigate("/projects");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const uploadImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!resolvedCompanyId) throw new Error("No company selected");
|
||||
@@ -476,6 +491,8 @@ export function ProjectDetail() {
|
||||
onUpdate={(data) => updateProject.mutate(data)}
|
||||
onFieldUpdate={updateProjectField}
|
||||
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
|
||||
onArchive={(archived) => archiveProject.mutate(archived)}
|
||||
archivePending={archiveProject.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -22,11 +22,15 @@ export function Projects() {
|
||||
setBreadcrumbs([{ label: "Projects" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: projects, isLoading, error } = useQuery({
|
||||
const { data: allProjects, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const projects = useMemo(
|
||||
() => (allProjects ?? []).filter((p) => !p.archivedAt),
|
||||
[allProjects],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
||||
@@ -47,7 +51,7 @@ export function Projects() {
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{projects && projects.length === 0 && (
|
||||
{!isLoading && projects.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Hexagon}
|
||||
message="No projects yet."
|
||||
@@ -56,7 +60,7 @@ export function Projects() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
{projects.length > 0 && (
|
||||
<div className="border border-border">
|
||||
{projects.map((project) => (
|
||||
<EntityRow
|
||||
|
||||
@@ -110,6 +110,7 @@ const entityScopedZones = new Set<PluginLauncherPlacementZone>([
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"projectSidebarItem",
|
||||
"toolbarButton",
|
||||
]);
|
||||
const focusableElementSelector = [
|
||||
"button:not([disabled])",
|
||||
@@ -195,6 +196,9 @@ function launcherTriggerClassName(placementZone: PluginLauncherPlacementZone): s
|
||||
case "sidebar":
|
||||
case "sidebarPanel":
|
||||
return "justify-start h-8 w-full";
|
||||
case "toolbarButton":
|
||||
case "globalToolbarButton":
|
||||
return "h-8";
|
||||
default:
|
||||
return "h-8";
|
||||
}
|
||||
@@ -732,7 +736,7 @@ function DefaultLauncherTrigger({
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={placementZone === "toolbarButton" ? "outline" : "ghost"}
|
||||
variant={placementZone === "toolbarButton" || placementZone === "globalToolbarButton" ? "outline" : "ghost"}
|
||||
size="sm"
|
||||
className={launcherTriggerClassName(placementZone)}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -102,7 +102,7 @@ function buildRegistryKey(pluginKey: string, exportName: string): string {
|
||||
}
|
||||
|
||||
function requiresEntityType(slotType: PluginUiSlotType): boolean {
|
||||
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem";
|
||||
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem" || slotType === "toolbarButton";
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
|
||||
Reference in New Issue
Block a user