forked from farhoodlabs/paperclip
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82e40fb370 | |||
| 06d692e87d | |||
| cb15a87f64 | |||
| 6094081a71 | |||
| e739a2d130 | |||
| 3610559cc7 | |||
| 157729ea95 | |||
| 9615c90c4f | |||
| 2e522c9b37 | |||
| ba44667ae3 | |||
| 9f15436e05 | |||
| 3144df70d6 | |||
| 268806ff1c | |||
| a77fc31dc1 | |||
| aed1a886a9 | |||
| ddf36667f9 | |||
| e2af316b3e | |||
| 92afa0fb67 | |||
| 9c7a17e16c | |||
| 119bf6336e | |||
| 6b30178766 | |||
| 8b64cf10ef | |||
| f44ae212f6 | |||
| a1343ea4f5 | |||
| e454277d10 | |||
| 4c413429be | |||
| 2d05002d73 | |||
| 772130a573 | |||
| edc77da082 | |||
| ec4e94a6e7 | |||
| 0b224f0864 | |||
| 54c5e7cb41 | |||
| 022a0df61a | |||
| b672ebb540 | |||
| 4640417166 | |||
| ae0b344915 | |||
| 26155c2b90 | |||
| 99c3289d8e | |||
| 8dff385086 | |||
| 002c470ee7 | |||
| 21411b80b2 | |||
| 7d55b8d9d0 | |||
| b8133d6a35 | |||
| 9175a8ee85 | |||
| 1956ccd7b5 | |||
| e3c172a06f | |||
| 89909db27c | |||
| d9341795b0 | |||
| ef7e6be8bb | |||
| 3674cef645 | |||
| 296d051bd5 | |||
| 45892739a5 | |||
| 857e9e4f01 | |||
| fa03b5944e |
@@ -1,55 +1,53 @@
|
||||
name: Docker
|
||||
name: Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
tags:
|
||||
- "v*"
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
runs-on: runners-farhoodliquor
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
images: ghcr.io/farhoodliquor/paperclip
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
no-cache: true
|
||||
@@ -1,261 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: Commit SHA, branch, or tag to publish as stable
|
||||
required: true
|
||||
type: string
|
||||
default: master
|
||||
stable_date:
|
||||
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Preview the stable release without publishing
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify_canary:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
publish_canary:
|
||||
if: github.event_name == 'push'
|
||||
needs: verify_canary
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-canary
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Restore tracked install-time changes
|
||||
run: git checkout -- pnpm-lock.yaml
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Publish canary
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: ./scripts/release.sh canary --skip-verify
|
||||
|
||||
- name: Push canary tag
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Error: no canary tag points at HEAD after release." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "refs/tags/${tag}"
|
||||
|
||||
verify_stable:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
preview_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
|
||||
needs: verify_stable
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Dry-run stable release
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: |
|
||||
args=(stable --skip-verify --dry-run)
|
||||
if [ -n "${{ inputs.stable_date }}" ]; then
|
||||
args+=(--date "${{ inputs.stable_date }}")
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
publish_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
|
||||
needs: verify_stable
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-stable
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Restore tracked install-time changes
|
||||
run: git checkout -- pnpm-lock.yaml
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Publish stable
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: |
|
||||
args=(stable --skip-verify)
|
||||
if [ -n "${{ inputs.stable_date }}" ]; then
|
||||
args+=(--date "${{ inputs.stable_date }}")
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Push stable tag
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Error: no stable tag points at HEAD after release." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "refs/tags/${tag}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PUBLISH_REMOTE: origin
|
||||
run: |
|
||||
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: no v* tag points at HEAD after stable release." >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/create-github-release.sh "$version"
|
||||
+16
-10
@@ -2,16 +2,11 @@ FROM node:lts-trixie-slim AS base
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \
|
||||
&& mkdir -p -m 755 /etc/apt/keyrings \
|
||||
&& wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
&& echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \
|
||||
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& mkdir -p -m 755 /etc/apt/sources.list.d \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gh \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 passwd \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \
|
||||
&& mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \
|
||||
&& rm -rf /tmp/gh_* \
|
||||
&& corepack enable
|
||||
|
||||
# Modify the existing node user/group to have the specified UID/GID to match host user
|
||||
@@ -55,7 +50,18 @@ ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends jq nano vim \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
|
||||
&& chmod +x /usr/local/bin/kubectl \
|
||||
&& curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \
|
||||
&& mv /tmp/kubeseal /usr/local/bin/kubeseal \
|
||||
&& rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& mv /root/.local/bin/uv /usr/local/bin/uv \
|
||||
&& mv /root/.local/bin/uvx /usr/local/bin/uvx \
|
||||
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai hermes-paperclip-adapter \
|
||||
&& mkdir -p /paperclip \
|
||||
&& chown node:node /paperclip
|
||||
|
||||
|
||||
@@ -557,6 +557,7 @@ export {
|
||||
companySkillDetailSchema,
|
||||
companySkillUpdateStatusSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillUpdateAuthSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillProjectScanSkippedSchema,
|
||||
companySkillProjectScanConflictSchema,
|
||||
|
||||
@@ -66,6 +66,11 @@ export const companySkillUpdateStatusSchema = z.object({
|
||||
|
||||
export const companySkillImportSchema = z.object({
|
||||
source: z.string().min(1),
|
||||
authToken: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const companySkillUpdateAuthSchema = z.object({
|
||||
authToken: z.string().min(1).nullable(),
|
||||
});
|
||||
|
||||
export const companySkillProjectScanRequestSchema = z.object({
|
||||
@@ -133,3 +138,4 @@ export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
|
||||
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
|
||||
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
|
||||
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
|
||||
export type CompanySkillUpdateAuth = z.infer<typeof companySkillUpdateAuthSchema>;
|
||||
|
||||
@@ -44,6 +44,7 @@ export {
|
||||
companySkillDetailSchema,
|
||||
companySkillUpdateStatusSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillUpdateAuthSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
companySkillProjectScanSkippedSchema,
|
||||
companySkillProjectScanConflictSchema,
|
||||
@@ -55,6 +56,7 @@ export {
|
||||
type CompanySkillProjectScan,
|
||||
type CompanySkillCreate,
|
||||
type CompanySkillFileUpdate,
|
||||
type CompanySkillUpdateAuth,
|
||||
} from "./company-skill.js";
|
||||
export {
|
||||
agentSkillStateSchema,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("hermes-paperclip-adapter/server", () => ({
|
||||
execute: vi.fn(),
|
||||
testEnvironment: vi.fn(),
|
||||
sessionCodec: vi.fn(),
|
||||
listSkills: vi.fn(async () => []),
|
||||
syncSkills: vi.fn(async () => []),
|
||||
detectModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("hermes-paperclip-adapter", () => ({
|
||||
agentConfigurationDoc: vi.fn(),
|
||||
models: [],
|
||||
}));
|
||||
|
||||
import { withAdapterMutex } from "../routes/adapters.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withAdapterMutex — serialisation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("withAdapterMutex", () => {
|
||||
it("serialises concurrent calls so they do not overlap", async () => {
|
||||
const log: string[] = [];
|
||||
|
||||
const taskA = withAdapterMutex(async () => {
|
||||
log.push("A-start");
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
log.push("A-end");
|
||||
return "a";
|
||||
});
|
||||
|
||||
const taskB = withAdapterMutex(async () => {
|
||||
log.push("B-start");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
log.push("B-end");
|
||||
return "b";
|
||||
});
|
||||
|
||||
const [resultA, resultB] = await Promise.all([taskA, taskB]);
|
||||
|
||||
expect(resultA).toBe("a");
|
||||
expect(resultB).toBe("b");
|
||||
|
||||
// A must fully complete before B starts (FIFO serialisation)
|
||||
expect(log).toEqual(["A-start", "A-end", "B-start", "B-end"]);
|
||||
});
|
||||
|
||||
it("a failed task does not block subsequent tasks", async () => {
|
||||
const failing = withAdapterMutex(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
await expect(failing).rejects.toThrow("boom");
|
||||
|
||||
const ok = await withAdapterMutex(async () => "ok");
|
||||
expect(ok).toBe("ok");
|
||||
});
|
||||
|
||||
it("preserves FIFO order for many concurrent callers", async () => {
|
||||
const order: number[] = [];
|
||||
|
||||
const tasks = Array.from({ length: 5 }, (_, i) =>
|
||||
withAdapterMutex(async () => {
|
||||
order.push(i);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(tasks);
|
||||
expect(order).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("returns the value from the inner function", async () => {
|
||||
const result = await withAdapterMutex(async () => ({ answer: 42 }));
|
||||
expect(result).toEqual({ answer: 42 });
|
||||
});
|
||||
|
||||
it("propagates errors from the inner function", async () => {
|
||||
await expect(
|
||||
withAdapterMutex(async () => {
|
||||
throw new TypeError("bad input");
|
||||
}),
|
||||
).rejects.toThrow("bad input");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// adapter-plugin-store — atomic write tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("adapter-plugin-store atomic writes", () => {
|
||||
// We test the store in an isolated temp directory by stubbing HOME
|
||||
const originalHome = process.env.HOME;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "adapter-store-test-"));
|
||||
process.env.HOME = tmpDir;
|
||||
// Force the store module to pick up the new HOME by resetting its cache.
|
||||
// We re-import it fresh for each test.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = originalHome;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writeStore creates the file atomically via rename", async () => {
|
||||
// Dynamically import the store so it uses the stubbed HOME
|
||||
vi.resetModules();
|
||||
const store = await import("../services/adapter-plugin-store.js");
|
||||
|
||||
const record = {
|
||||
packageName: "test-adapter",
|
||||
type: "test_type",
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
store.addAdapterPlugin(record);
|
||||
|
||||
// The store file should exist and contain the record
|
||||
const storePath = path.join(tmpDir, ".paperclip", "adapter-plugins.json");
|
||||
expect(fs.existsSync(storePath)).toBe(true);
|
||||
|
||||
const contents = JSON.parse(fs.readFileSync(storePath, "utf-8"));
|
||||
expect(contents).toHaveLength(1);
|
||||
expect(contents[0].packageName).toBe("test-adapter");
|
||||
|
||||
// No lingering temp files
|
||||
const dir = path.dirname(storePath);
|
||||
const tmpFiles = fs.readdirSync(dir).filter((f) => f.endsWith(".tmp"));
|
||||
expect(tmpFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("addAdapterPlugin is idempotent for the same type", async () => {
|
||||
vi.resetModules();
|
||||
const store = await import("../services/adapter-plugin-store.js");
|
||||
|
||||
const record1 = {
|
||||
packageName: "pkg-a",
|
||||
type: "my_adapter",
|
||||
version: "1.0.0",
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
const record2 = {
|
||||
packageName: "pkg-a",
|
||||
type: "my_adapter",
|
||||
version: "2.0.0",
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
store.addAdapterPlugin(record1);
|
||||
store.addAdapterPlugin(record2);
|
||||
|
||||
const plugins = store.listAdapterPlugins();
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("removeAdapterPlugin removes the correct record", async () => {
|
||||
vi.resetModules();
|
||||
const store = await import("../services/adapter-plugin-store.js");
|
||||
|
||||
store.addAdapterPlugin({
|
||||
packageName: "a",
|
||||
type: "type_a",
|
||||
installedAt: new Date().toISOString(),
|
||||
});
|
||||
store.addAdapterPlugin({
|
||||
packageName: "b",
|
||||
type: "type_b",
|
||||
installedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(store.listAdapterPlugins()).toHaveLength(2);
|
||||
|
||||
const removed = store.removeAdapterPlugin("type_a");
|
||||
expect(removed).toBe(true);
|
||||
expect(store.listAdapterPlugins()).toHaveLength(1);
|
||||
expect(store.listAdapterPlugins()[0].type).toBe("type_b");
|
||||
});
|
||||
|
||||
it("removeAdapterPlugin returns false for unknown type", async () => {
|
||||
vi.resetModules();
|
||||
const store = await import("../services/adapter-plugin-store.js");
|
||||
expect(store.removeAdapterPlugin("nonexistent")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
// Mock the adapter layer so usage() never tries to call real adapters
|
||||
vi.mock("../adapters/index.js", () => ({
|
||||
findActiveServerAdapter: () => null,
|
||||
}));
|
||||
|
||||
// Mock ghFetch so readUrlSkillImports never makes real HTTP calls.
|
||||
// gitHubApiBase and resolveRawGitHubUrl are pure functions — pass them through.
|
||||
const mockGhFetch = vi.fn();
|
||||
vi.mock("../services/github-fetch.js", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../services/github-fetch.js")>();
|
||||
return {
|
||||
...original,
|
||||
ghFetch: (...args: unknown[]) => mockGhFetch(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping company-skills prune tests: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("scanProjectWorkspaces prune path", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const skillKeepId = randomUUID();
|
||||
const skillPruneId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
||||
const sourceLocator = "https://github.com/test-org/test-skills";
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-skills-prune-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Seed company
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Test Co",
|
||||
issuePrefix: `T${Date.now().toString(36).slice(-4).toUpperCase()}`,
|
||||
});
|
||||
|
||||
// Seed project (no workspaces — local scan phase will find nothing)
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Test Project",
|
||||
});
|
||||
|
||||
// Seed agent with both skills in desired config
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Builder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [
|
||||
`test-org/test-skills/keep-skill`,
|
||||
`test-org/test-skills/prune-skill`,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Seed two GitHub-sourced skills from the same repo
|
||||
await db.insert(companySkills).values([
|
||||
{
|
||||
id: skillKeepId,
|
||||
companyId,
|
||||
key: "test-org/test-skills/keep-skill",
|
||||
slug: "keep-skill",
|
||||
name: "Keep Skill",
|
||||
markdown: "# Keep Skill",
|
||||
sourceType: "github",
|
||||
sourceLocator,
|
||||
sourceRef: "abc123",
|
||||
metadata: { sourceKind: "github", owner: "test-org", repo: "test-skills", ref: "abc123", trackingRef: "main" },
|
||||
},
|
||||
{
|
||||
id: skillPruneId,
|
||||
companyId,
|
||||
key: "test-org/test-skills/prune-skill",
|
||||
slug: "prune-skill",
|
||||
name: "Prune Skill",
|
||||
markdown: "# Prune Skill",
|
||||
sourceType: "github",
|
||||
sourceLocator,
|
||||
sourceRef: "abc123",
|
||||
metadata: { sourceKind: "github", owner: "test-org", repo: "test-skills", ref: "abc123", trackingRef: "main" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
mockGhFetch.mockReset();
|
||||
await db.delete(companySkills).where(eq(companySkills.companyId, companyId));
|
||||
await db.delete(projects).where(eq(projects.companyId, companyId));
|
||||
await db.delete(agents).where(eq(agents.companyId, companyId));
|
||||
await db.delete(companies).where(eq(companies.id, companyId));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: configure mockGhFetch to simulate a GitHub repo that contains
|
||||
* only the specified skill slugs (each with a SKILL.md in its directory).
|
||||
*/
|
||||
function stubGitHubSource(slugs: string[]) {
|
||||
const sha = "deadbeef".repeat(5);
|
||||
const tree = slugs.map((slug) => ({
|
||||
path: `${slug}/SKILL.md`,
|
||||
type: "blob",
|
||||
}));
|
||||
|
||||
mockGhFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
const u = url;
|
||||
|
||||
// GET /repos/{owner}/{repo} → default branch
|
||||
if (u.match(/\/repos\/test-org\/test-skills$/)) {
|
||||
return new Response(JSON.stringify({ default_branch: "main" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// GET /repos/{owner}/{repo}/commits/{ref} → pinned SHA
|
||||
if (u.includes("/commits/")) {
|
||||
return new Response(JSON.stringify({ sha }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1 → file tree
|
||||
if (u.includes("/git/trees/")) {
|
||||
return new Response(JSON.stringify({ tree }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// GET raw content for SKILL.md files
|
||||
if (u.includes("raw.githubusercontent.com") || u.includes("/raw/")) {
|
||||
const match = u.match(/\/([^/]+)\/SKILL\.md$/);
|
||||
const slug = match?.[1] ?? "unknown";
|
||||
const markdown = [
|
||||
"---",
|
||||
`name: ${slug}`,
|
||||
`slug: ${slug}`,
|
||||
"---",
|
||||
"",
|
||||
`# ${slug}`,
|
||||
"",
|
||||
].join("\n");
|
||||
return new Response(markdown, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
}
|
||||
|
||||
// Default: 404
|
||||
return new Response("Not Found", { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
it("prunes a skill removed from the source, detaches it from agents, and emits a warning", async () => {
|
||||
// GitHub returns only "keep-skill" — "prune-skill" is no longer in the repo
|
||||
stubGitHubSource(["keep-skill"]);
|
||||
|
||||
const { companySkillService } = await import("../services/company-skills.js");
|
||||
const svc = companySkillService(db);
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {});
|
||||
|
||||
// The pruned skill should be deleted from the database
|
||||
const remaining = await db
|
||||
.select()
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId));
|
||||
const remainingSlugs = remaining.map((row) => row.slug);
|
||||
expect(remainingSlugs).toContain("keep-skill");
|
||||
expect(remainingSlugs).not.toContain("prune-skill");
|
||||
|
||||
// The agent's desired skills should no longer include the pruned skill key
|
||||
const [agentRow] = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId));
|
||||
const config = agentRow!.adapterConfig as Record<string, unknown>;
|
||||
const syncConfig = config.paperclipSkillSync as Record<string, unknown>;
|
||||
const desiredSkills = syncConfig.desiredSkills as string[];
|
||||
expect(desiredSkills).not.toContain("test-org/test-skills/prune-skill");
|
||||
expect(desiredSkills).toContain("test-org/test-skills/keep-skill");
|
||||
|
||||
// A warning should be emitted about the detached skill
|
||||
expect(result.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("prune-skill"),
|
||||
expect.stringContaining("Builder"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes a pruned skill even when no agents reference it", async () => {
|
||||
// Remove the prune skill from the agent's desired skills before scanning
|
||||
await db.update(agents).set({
|
||||
adapterConfig: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["test-org/test-skills/keep-skill"],
|
||||
},
|
||||
},
|
||||
}).where(eq(agents.id, agentId));
|
||||
|
||||
stubGitHubSource(["keep-skill"]);
|
||||
|
||||
const { companySkillService } = await import("../services/company-skills.js");
|
||||
const svc = companySkillService(db);
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {});
|
||||
|
||||
const remaining = await db
|
||||
.select()
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId));
|
||||
const remainingSlugs = remaining.map((r) => r.slug);
|
||||
expect(remainingSlugs).toContain("keep-skill");
|
||||
expect(remainingSlugs).not.toContain("prune-skill");
|
||||
|
||||
// A deletion warning should still be emitted (no detachment, but deletion is confirmed)
|
||||
const deleteWarnings = result.warnings.filter((w) => w.includes("prune-skill") && w.includes("deleted"));
|
||||
expect(deleteWarnings).toHaveLength(1);
|
||||
// No agent-detachment warning since no agents used it
|
||||
const detachWarnings = result.warnings.filter((w) => w.includes("prune-skill") && w.includes("detached"));
|
||||
expect(detachWarnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips pruning when the source fetch fails and emits a warning", async () => {
|
||||
// Simulate GitHub being down
|
||||
mockGhFetch.mockRejectedValue(new Error("network error"));
|
||||
|
||||
const { companySkillService } = await import("../services/company-skills.js");
|
||||
const svc = companySkillService(db);
|
||||
const result = await svc.scanProjectWorkspaces(companyId, {});
|
||||
|
||||
// Both seeded skills should still exist (bundled skills may also be present)
|
||||
const remaining = await db
|
||||
.select()
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId));
|
||||
const remainingSlugs = remaining.map((r) => r.slug);
|
||||
expect(remainingSlugs).toContain("keep-skill");
|
||||
expect(remainingSlugs).toContain("prune-skill");
|
||||
|
||||
// A warning should mention the failed source
|
||||
expect(result.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Could not re-scan source"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const mockAccessService = vi.hoisted(() => ({
|
||||
const mockCompanySkillService = vi.hoisted(() => ({
|
||||
importFromSource: vi.fn(),
|
||||
deleteSkill: vi.fn(),
|
||||
scanProjectWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
@@ -69,6 +70,16 @@ describe("company skill mutation permissions", () => {
|
||||
slug: "find-skills",
|
||||
name: "Find Skills",
|
||||
});
|
||||
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
|
||||
scannedProjects: 1,
|
||||
scannedWorkspaces: 2,
|
||||
discovered: [],
|
||||
imported: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
conflicts: [],
|
||||
warnings: [],
|
||||
});
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
@@ -89,6 +100,7 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -246,6 +258,61 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows agents with canCreateAgents to scan project workspaces", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateAgents: true },
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {});
|
||||
});
|
||||
|
||||
it("returns warnings from scan when removed skills are still used by agents", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateAgents: true },
|
||||
});
|
||||
|
||||
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValueOnce({
|
||||
scannedProjects: 1,
|
||||
scannedWorkspaces: 1,
|
||||
discovered: [],
|
||||
imported: [],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
conflicts: [],
|
||||
warnings: [
|
||||
'Skill "ghost-skill" was removed from https://github.com/vercel-labs/agent-browser and detached from Builder.',
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/scan-projects")
|
||||
.send({});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
warnings: [expect.stringContaining("was removed from")],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows agents with canCreateAgents to mutate company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
@@ -266,6 +333,7 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -171,3 +171,27 @@ describe("startServer feedback export wiring", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("startServer PAPERCLIP_API_URL handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.BETTER_AUTH_SECRET = "test-secret";
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
});
|
||||
|
||||
it("uses the externally set PAPERCLIP_API_URL when provided", async () => {
|
||||
process.env.PAPERCLIP_API_URL = "http://custom-api:3100";
|
||||
|
||||
const started = await startServer();
|
||||
|
||||
expect(started.apiUrl).toBe("http://custom-api:3100");
|
||||
expect(process.env.PAPERCLIP_API_URL).toBe("http://custom-api:3100");
|
||||
});
|
||||
|
||||
it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => {
|
||||
const started = await startServer();
|
||||
|
||||
expect(started.apiUrl).toBe("http://127.0.0.1:3210");
|
||||
expect(process.env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:3210");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,15 @@
|
||||
* adapter-utils, never registry.ts.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { ServerAdapterModule } from "./types.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
import {
|
||||
listAdapterPlugins,
|
||||
getAdapterPluginsDir,
|
||||
@@ -187,8 +191,37 @@ export async function loadExternalAdapterPackage(
|
||||
return adapterModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an npm-sourced adapter package exists on disk.
|
||||
* If the package directory is missing (e.g. pruned by a prior --no-save install),
|
||||
* reinstall it from the registry so reload/boot can proceed.
|
||||
*/
|
||||
async function ensurePackageOnDisk(record: AdapterPluginRecord): Promise<void> {
|
||||
if (record.localPath) return; // local-path adapters are managed externally
|
||||
|
||||
const packageDir = resolvePackageDir(record);
|
||||
const pkgJsonPath = path.join(packageDir, "package.json");
|
||||
if (fs.existsSync(pkgJsonPath)) return; // already on disk
|
||||
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
const spec = record.version
|
||||
? `${record.packageName}@${record.version}`
|
||||
: record.packageName;
|
||||
|
||||
logger.info(
|
||||
{ packageName: record.packageName, type: record.type, spec },
|
||||
"Adapter package missing from disk — reinstalling from npm",
|
||||
);
|
||||
|
||||
await execFileAsync("npm", ["install", spec], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFromRecord(record: AdapterPluginRecord): Promise<ServerAdapterModule | null> {
|
||||
try {
|
||||
await ensurePackageOnDisk(record);
|
||||
return await loadExternalAdapterPackage(record.packageName, record.localPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
@@ -209,6 +242,8 @@ export async function reloadExternalAdapter(
|
||||
const record = getAdapterPluginByType(type);
|
||||
if (!record) return null;
|
||||
|
||||
await ensurePackageOnDisk(record);
|
||||
|
||||
const packageDir = resolvePackageDir(record);
|
||||
const entryPoint = resolvePackageEntryPoint(packageDir);
|
||||
const modulePath = path.resolve(packageDir, entryPoint);
|
||||
|
||||
+4
-2
@@ -553,7 +553,9 @@ export async function startServer(): Promise<StartedServer> {
|
||||
: runtimeListenHost;
|
||||
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
|
||||
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
|
||||
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
|
||||
if (!process.env.PAPERCLIP_API_URL) {
|
||||
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
|
||||
}
|
||||
|
||||
setupLiveEventsWebSocketServer(server, db as any, {
|
||||
deploymentMode: config.deploymentMode,
|
||||
@@ -765,7 +767,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||
server,
|
||||
host: config.host,
|
||||
listenPort,
|
||||
apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`,
|
||||
apiUrl: process.env.PAPERCLIP_API_URL!,
|
||||
databaseUrl: activeDatabaseConnectionString,
|
||||
};
|
||||
}
|
||||
|
||||
+224
-197
@@ -46,6 +46,26 @@ import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency control — serialise all install / reinstall / reload / delete
|
||||
// operations so that concurrent requests cannot race on npm, the plugin store
|
||||
// JSON file, or the in-memory adapter registry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mutexQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Enqueue `fn` behind any in-flight adapter mutation. Only one mutation runs
|
||||
* at a time; the rest wait in FIFO order.
|
||||
*/
|
||||
export function withAdapterMutex<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const ticket = mutexQueue.then(fn, fn); // always run `fn` after the queue settles
|
||||
// Swallow rejections on the queue itself so a failed operation doesn't
|
||||
// permanently block subsequent ones.
|
||||
mutexQueue = ticket.then(() => {}, () => {});
|
||||
return ticket;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -211,8 +231,8 @@ export function adapterRoutes() {
|
||||
|
||||
// Strip version suffix if the UI sends "pkg@1.2.3" instead of separating it
|
||||
// e.g. "@henkey/hermes-paperclip-adapter@0.3.0" → packageName + version
|
||||
let canonicalName = packageName;
|
||||
let explicitVersion = version;
|
||||
let canonicalName = packageName.trim();
|
||||
let explicitVersion = typeof version === "string" ? version.trim() : version;
|
||||
const versionSuffix = packageName.match(/@(\d+\.\d+\.\d+.*)$/);
|
||||
if (versionSuffix) {
|
||||
// For scoped packages: "@scope/name@1.2.3" → "@scope/name" + "1.2.3"
|
||||
@@ -224,103 +244,104 @@ export function adapterRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let installedVersion: string | undefined;
|
||||
let moduleLocalPath: string | undefined;
|
||||
await withAdapterMutex(async () => {
|
||||
try {
|
||||
let installedVersion: string | undefined;
|
||||
let moduleLocalPath: string | undefined;
|
||||
|
||||
if (!isLocalPath) {
|
||||
// npm install into the managed directory
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
const spec = explicitVersion ? `${canonicalName}@${explicitVersion}` : canonicalName;
|
||||
if (!isLocalPath) {
|
||||
// npm install into the managed directory
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
const spec = explicitVersion ? `${canonicalName}@${explicitVersion}` : canonicalName;
|
||||
|
||||
logger.info({ spec, pluginsDir }, "Installing adapter package via npm");
|
||||
logger.info({ spec, pluginsDir }, "Installing adapter package via npm");
|
||||
|
||||
await execFileAsync("npm", ["install", "--no-save", spec], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 120_000,
|
||||
});
|
||||
await execFileAsync("npm", ["install", spec], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
// Read installed version from package.json
|
||||
try {
|
||||
const pkgJsonPath = path.join(pluginsDir, "node_modules", canonicalName, "package.json");
|
||||
const pkgContent = await import("node:fs/promises");
|
||||
const pkgRaw = await pkgContent.readFile(pkgJsonPath, "utf-8");
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
const v = pkg.version;
|
||||
installedVersion =
|
||||
typeof v === "string" && v.trim().length > 0 ? v.trim() : explicitVersion;
|
||||
} catch {
|
||||
installedVersion = explicitVersion;
|
||||
}
|
||||
} else {
|
||||
// Local path — normalize (e.g., Windows → WSL) and use the resolved path
|
||||
moduleLocalPath = path.resolve(await normalizeLocalPath(packageName));
|
||||
try {
|
||||
const pkgRaw = await readFile(path.join(moduleLocalPath, "package.json"), "utf-8");
|
||||
const v = JSON.parse(pkgRaw).version;
|
||||
if (typeof v === "string" && v.trim().length > 0) {
|
||||
installedVersion = v.trim();
|
||||
// Read installed version from package.json
|
||||
try {
|
||||
const pkgJsonPath = path.join(pluginsDir, "node_modules", canonicalName, "package.json");
|
||||
const pkgRaw = await readFile(pkgJsonPath, "utf-8");
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
const v = pkg.version;
|
||||
installedVersion =
|
||||
typeof v === "string" && v.trim().length > 0 ? v.trim() : explicitVersion;
|
||||
} catch {
|
||||
installedVersion = explicitVersion;
|
||||
}
|
||||
} catch {
|
||||
// leave installedVersion undefined if package.json is missing
|
||||
} else {
|
||||
// Local path — normalize (e.g., Windows → WSL) and use the resolved path
|
||||
moduleLocalPath = path.resolve(await normalizeLocalPath(packageName));
|
||||
try {
|
||||
const pkgRaw = await readFile(path.join(moduleLocalPath, "package.json"), "utf-8");
|
||||
const v = JSON.parse(pkgRaw).version;
|
||||
if (typeof v === "string" && v.trim().length > 0) {
|
||||
installedVersion = v.trim();
|
||||
}
|
||||
} catch {
|
||||
// leave installedVersion undefined if package.json is missing
|
||||
}
|
||||
}
|
||||
|
||||
// Load and register the adapter (use canonicalName for path resolution)
|
||||
const adapterModule = await loadExternalAdapterPackage(canonicalName, moduleLocalPath);
|
||||
|
||||
// Check if this type conflicts with a built-in adapter
|
||||
if (BUILTIN_ADAPTER_TYPES.has(adapterModule.type)) {
|
||||
res.status(409).json({
|
||||
error: `Adapter type "${adapterModule.type}" is a built-in adapter and cannot be overwritten.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already registered (indicates a reinstall/update)
|
||||
const existing = findServerAdapter(adapterModule.type);
|
||||
const isReinstall = existing !== null;
|
||||
if (existing) {
|
||||
unregisterServerAdapter(adapterModule.type);
|
||||
logger.info({ type: adapterModule.type }, "Unregistered existing adapter for replacement");
|
||||
}
|
||||
|
||||
// Register the new adapter
|
||||
registerWithSessionManagement(adapterModule);
|
||||
|
||||
// Persist the record (use canonicalName without version suffix)
|
||||
const record: AdapterPluginRecord = {
|
||||
packageName: canonicalName,
|
||||
localPath: moduleLocalPath,
|
||||
version: installedVersion ?? explicitVersion,
|
||||
type: adapterModule.type,
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
addAdapterPlugin(record);
|
||||
|
||||
logger.info(
|
||||
{ type: adapterModule.type, packageName: canonicalName },
|
||||
"External adapter installed and registered",
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
type: adapterModule.type,
|
||||
packageName: canonicalName,
|
||||
version: installedVersion ?? explicitVersion,
|
||||
installedAt: record.installedAt,
|
||||
requiresRestart: isReinstall,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, packageName }, "Failed to install external adapter");
|
||||
|
||||
// Distinguish npm errors from load errors
|
||||
if (message.includes("npm") || message.includes("ERR!")) {
|
||||
res.status(500).json({ error: `npm install failed: ${message}` });
|
||||
} else {
|
||||
res.status(500).json({ error: `Failed to install adapter: ${message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Load and register the adapter (use canonicalName for path resolution)
|
||||
const adapterModule = await loadExternalAdapterPackage(canonicalName, moduleLocalPath);
|
||||
|
||||
// Check if this type conflicts with a built-in adapter
|
||||
if (BUILTIN_ADAPTER_TYPES.has(adapterModule.type)) {
|
||||
res.status(409).json({
|
||||
error: `Adapter type "${adapterModule.type}" is a built-in adapter and cannot be overwritten.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already registered (indicates a reinstall/update)
|
||||
const existing = findServerAdapter(adapterModule.type);
|
||||
const isReinstall = existing !== null;
|
||||
if (existing) {
|
||||
unregisterServerAdapter(adapterModule.type);
|
||||
logger.info({ type: adapterModule.type }, "Unregistered existing adapter for replacement");
|
||||
}
|
||||
|
||||
// Register the new adapter
|
||||
registerWithSessionManagement(adapterModule);
|
||||
|
||||
// Persist the record (use canonicalName without version suffix)
|
||||
const record: AdapterPluginRecord = {
|
||||
packageName: canonicalName,
|
||||
localPath: moduleLocalPath,
|
||||
version: installedVersion ?? explicitVersion,
|
||||
type: adapterModule.type,
|
||||
installedAt: new Date().toISOString(),
|
||||
};
|
||||
addAdapterPlugin(record);
|
||||
|
||||
logger.info(
|
||||
{ type: adapterModule.type, packageName: canonicalName },
|
||||
"External adapter installed and registered",
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
type: adapterModule.type,
|
||||
packageName: canonicalName,
|
||||
version: installedVersion ?? explicitVersion,
|
||||
installedAt: record.installedAt,
|
||||
requiresRestart: isReinstall,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, packageName }, "Failed to install external adapter");
|
||||
|
||||
// Distinguish npm errors from load errors
|
||||
if (message.includes("npm") || message.includes("ERR!")) {
|
||||
res.status(500).json({ error: `npm install failed: ${message}` });
|
||||
} else {
|
||||
res.status(500).json({ error: `Failed to install adapter: ${message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -412,53 +433,55 @@ export function adapterRoutes() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the adapter exists in the registry
|
||||
const existing = findServerAdapter(adapterType);
|
||||
if (!existing) {
|
||||
res.status(404).json({
|
||||
error: `Adapter "${adapterType}" is not registered.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that it's an external adapter
|
||||
const externalRecord = getAdapterPluginByType(adapterType);
|
||||
if (!externalRecord) {
|
||||
res.status(404).json({
|
||||
error: `Adapter "${adapterType}" is not an externally installed adapter.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If installed via npm (has packageName but no localPath), run npm uninstall
|
||||
if (externalRecord.packageName && !externalRecord.localPath) {
|
||||
try {
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
await execFileAsync("npm", ["uninstall", externalRecord.packageName], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 60_000,
|
||||
await withAdapterMutex(async () => {
|
||||
// Check that the adapter exists in the registry
|
||||
const existing = findServerAdapter(adapterType);
|
||||
if (!existing) {
|
||||
res.status(404).json({
|
||||
error: `Adapter "${adapterType}" is not registered.`,
|
||||
});
|
||||
logger.info(
|
||||
{ type: adapterType, packageName: externalRecord.packageName },
|
||||
"npm uninstall completed for external adapter",
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, type: adapterType, packageName: externalRecord.packageName },
|
||||
"npm uninstall failed for external adapter; continuing with unregister",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister from the runtime registry
|
||||
unregisterServerAdapter(adapterType);
|
||||
// Check that it's an external adapter
|
||||
const externalRecord = getAdapterPluginByType(adapterType);
|
||||
if (!externalRecord) {
|
||||
res.status(404).json({
|
||||
error: `Adapter "${adapterType}" is not an externally installed adapter.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from the persistent store
|
||||
removeAdapterPlugin(adapterType);
|
||||
// If installed via npm (has packageName but no localPath), run npm uninstall
|
||||
if (externalRecord.packageName && !externalRecord.localPath) {
|
||||
try {
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
await execFileAsync("npm", ["uninstall", externalRecord.packageName], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 60_000,
|
||||
});
|
||||
logger.info(
|
||||
{ type: adapterType, packageName: externalRecord.packageName },
|
||||
"npm uninstall completed for external adapter",
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, type: adapterType, packageName: externalRecord.packageName },
|
||||
"npm uninstall failed for external adapter; continuing with unregister",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ type: adapterType }, "External adapter unregistered and removed");
|
||||
// Unregister from the runtime registry
|
||||
unregisterServerAdapter(adapterType);
|
||||
|
||||
res.json({ type: adapterType, removed: true });
|
||||
// Remove from the persistent store
|
||||
removeAdapterPlugin(adapterType);
|
||||
|
||||
logger.info({ type: adapterType }, "External adapter unregistered and removed");
|
||||
|
||||
res.json({ type: adapterType, removed: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -480,39 +503,41 @@ export function adapterRoutes() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload the adapter module (busts ESM cache, re-imports)
|
||||
try {
|
||||
const newModule = await reloadExternalAdapter(type);
|
||||
await withAdapterMutex(async () => {
|
||||
// Reload the adapter module (busts ESM cache, re-imports)
|
||||
try {
|
||||
const newModule = await reloadExternalAdapter(type);
|
||||
|
||||
// Not found in the external adapter store
|
||||
if (!newModule) {
|
||||
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Swap in the reloaded module
|
||||
unregisterServerAdapter(type);
|
||||
registerWithSessionManagement(newModule);
|
||||
configSchemaCache.delete(type);
|
||||
|
||||
// Sync store.version from package.json (store may be missing version for local installs).
|
||||
const record = getAdapterPluginByType(type);
|
||||
let newVersion: string | undefined;
|
||||
if (record) {
|
||||
newVersion = readAdapterPackageVersionFromDisk(record);
|
||||
if (newVersion) {
|
||||
addAdapterPlugin({ ...record, version: newVersion });
|
||||
// Not found in the external adapter store
|
||||
if (!newModule) {
|
||||
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Swap in the reloaded module
|
||||
unregisterServerAdapter(type);
|
||||
registerWithSessionManagement(newModule);
|
||||
configSchemaCache.delete(type);
|
||||
|
||||
// Sync store.version from package.json (store may be missing version for local installs).
|
||||
const record = getAdapterPluginByType(type);
|
||||
let newVersion: string | undefined;
|
||||
if (record) {
|
||||
newVersion = readAdapterPackageVersionFromDisk(record);
|
||||
if (newVersion) {
|
||||
addAdapterPlugin({ ...record, version: newVersion });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ type, version: newVersion }, "External adapter reloaded at runtime");
|
||||
|
||||
res.json({ type, version: newVersion, reloaded: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, type }, "Failed to reload external adapter");
|
||||
res.status(500).json({ error: `Failed to reload adapter: ${message}` });
|
||||
}
|
||||
|
||||
logger.info({ type, version: newVersion }, "External adapter reloaded at runtime");
|
||||
|
||||
res.json({ type, version: newVersion, reloaded: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, type }, "Failed to reload external adapter");
|
||||
res.status(500).json({ error: `Failed to reload adapter: ${message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/adapters/:type/reinstall ──────────────────────────────────
|
||||
@@ -542,45 +567,47 @@ export function adapterRoutes() {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
await withAdapterMutex(async () => {
|
||||
try {
|
||||
const pluginsDir = getAdapterPluginsDir();
|
||||
|
||||
logger.info({ type, packageName: record.packageName }, "Reinstalling adapter package via npm");
|
||||
logger.info({ type, packageName: record.packageName }, "Reinstalling adapter package via npm");
|
||||
|
||||
await execFileAsync("npm", ["install", "--no-save", record.packageName], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 120_000,
|
||||
});
|
||||
await execFileAsync("npm", ["install", record.packageName.trim()], {
|
||||
cwd: pluginsDir,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
// Reload the freshly installed adapter
|
||||
const newModule = await reloadExternalAdapter(type);
|
||||
if (!newModule) {
|
||||
res.status(500).json({ error: "npm install succeeded but adapter reload failed." });
|
||||
return;
|
||||
}
|
||||
|
||||
unregisterServerAdapter(type);
|
||||
registerWithSessionManagement(newModule);
|
||||
configSchemaCache.delete(type);
|
||||
|
||||
// Sync store version from disk
|
||||
let newVersion: string | undefined;
|
||||
const updatedRecord = getAdapterPluginByType(type);
|
||||
if (updatedRecord) {
|
||||
newVersion = readAdapterPackageVersionFromDisk(updatedRecord);
|
||||
if (newVersion) {
|
||||
addAdapterPlugin({ ...updatedRecord, version: newVersion });
|
||||
// Reload the freshly installed adapter
|
||||
const newModule = await reloadExternalAdapter(type);
|
||||
if (!newModule) {
|
||||
res.status(500).json({ error: "npm install succeeded but adapter reload failed." });
|
||||
return;
|
||||
}
|
||||
|
||||
unregisterServerAdapter(type);
|
||||
registerWithSessionManagement(newModule);
|
||||
configSchemaCache.delete(type);
|
||||
|
||||
// Sync store version from disk
|
||||
let newVersion: string | undefined;
|
||||
const updatedRecord = getAdapterPluginByType(type);
|
||||
if (updatedRecord) {
|
||||
newVersion = readAdapterPackageVersionFromDisk(updatedRecord);
|
||||
if (newVersion) {
|
||||
addAdapterPlugin({ ...updatedRecord, version: newVersion });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ type, version: newVersion }, "Adapter reinstalled from npm");
|
||||
|
||||
res.json({ type, version: newVersion, reinstalled: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, type }, "Failed to reinstall adapter");
|
||||
res.status(500).json({ error: `Reinstall failed: ${message}` });
|
||||
}
|
||||
|
||||
logger.info({ type, version: newVersion }, "Adapter reinstalled from npm");
|
||||
|
||||
res.json({ type, version: newVersion, reinstalled: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err, type }, "Failed to reinstall adapter");
|
||||
res.status(500).json({ error: `Reinstall failed: ${message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /api/adapters/:type/config-schema ────────────────────────────────
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
companySkillCreateSchema,
|
||||
companySkillFileUpdateSchema,
|
||||
companySkillImportSchema,
|
||||
companySkillUpdateAuthSchema,
|
||||
companySkillProjectScanRequestSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackSkillImported } from "@paperclipai/shared/telemetry";
|
||||
@@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const source = String(req.body.source ?? "");
|
||||
const result = await svc.importFromSource(companyId, source);
|
||||
const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined;
|
||||
const result = await svc.importFromSource(companyId, source, authToken || undefined);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -260,6 +262,36 @@ export function companySkillRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/companies/:companyId/skills/by-source", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const sourceLocator = String(req.query.source ?? "").trim();
|
||||
if (!sourceLocator) {
|
||||
res.status(400).json({ error: "source query parameter is required" });
|
||||
return;
|
||||
}
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const deleted = await svc.deleteBySource(companyId, sourceLocator);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "company.skills_source_deleted",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
sourceLocator,
|
||||
deletedCount: deleted.length,
|
||||
deletedSlugs: deleted.map((s) => s.slug),
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ deleted });
|
||||
});
|
||||
|
||||
router.delete("/companies/:companyId/skills/:skillId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
@@ -318,5 +350,38 @@ export function companySkillRoutes(db: Db) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/skills/:skillId/auth",
|
||||
validate(companySkillUpdateAuthSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const authToken = req.body.authToken as string | null;
|
||||
const result = await svc.updateSkillAuth(companyId, skillId, authToken);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed",
|
||||
entityType: "company_skill",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
slug: result.slug,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,12 @@ function readStore(): AdapterPluginRecord[] {
|
||||
|
||||
function writeStore(records: AdapterPluginRecord[]): void {
|
||||
ensureDirs();
|
||||
fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8");
|
||||
// Atomic write: write to a temp file in the same directory then rename.
|
||||
// rename() is atomic on POSIX when source and target are on the same
|
||||
// filesystem, preventing partial/corrupted reads from concurrent processes.
|
||||
const tmpPath = `${ADAPTER_PLUGINS_STORE_PATH}.tmp`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(records, null, 2), "utf-8");
|
||||
fs.renameSync(tmpPath, ADAPTER_PLUGINS_STORE_PATH);
|
||||
storeCache = records;
|
||||
}
|
||||
|
||||
@@ -106,7 +111,9 @@ function readSettings(): AdapterSettings {
|
||||
|
||||
function writeSettings(settings: AdapterSettings): void {
|
||||
ensureDirs();
|
||||
fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
|
||||
const tmpPath = `${ADAPTER_SETTINGS_PATH}.tmp`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), "utf-8");
|
||||
fs.renameSync(tmpPath, ADAPTER_SETTINGS_PATH);
|
||||
settingsCache = settings;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySkills } from "@paperclipai/db";
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type {
|
||||
CompanySkill,
|
||||
@@ -471,20 +471,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchText(url: string) {
|
||||
const response = await ghFetch(url);
|
||||
async function fetchText(url: string, authToken?: string) {
|
||||
const response = await ghFetch(url, undefined, authToken);
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
async function fetchJson<T>(url: string, authToken?: string): Promise<T> {
|
||||
const response = await ghFetch(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
},
|
||||
});
|
||||
}, authToken);
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
@@ -492,16 +492,18 @@ async function fetchJson<T>(url: string): Promise<T> {
|
||||
}
|
||||
|
||||
|
||||
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) {
|
||||
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) {
|
||||
const response = await fetchJson<{ default_branch?: string }>(
|
||||
`${apiBase}/repos/${owner}/${repo}`,
|
||||
authToken,
|
||||
);
|
||||
return asString(response.default_branch) ?? "main";
|
||||
}
|
||||
|
||||
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) {
|
||||
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) {
|
||||
const response = await fetchJson<{ sha?: string }>(
|
||||
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||
authToken,
|
||||
);
|
||||
const sha = asString(response.sha);
|
||||
if (!sha) {
|
||||
@@ -538,7 +540,7 @@ function parseGitHubSourceUrl(rawUrl: string) {
|
||||
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
||||
}
|
||||
|
||||
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
|
||||
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>, authToken?: string) {
|
||||
const apiBase = gitHubApiBase(parsed.hostname);
|
||||
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
|
||||
return {
|
||||
@@ -549,8 +551,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourc
|
||||
|
||||
const trackingRef = parsed.explicitRef
|
||||
? parsed.ref
|
||||
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
|
||||
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
|
||||
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase, authToken);
|
||||
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase, authToken);
|
||||
return { pinnedRef, trackingRef };
|
||||
}
|
||||
|
||||
@@ -981,6 +983,7 @@ async function readUrlSkillImports(
|
||||
companyId: string,
|
||||
sourceUrl: string,
|
||||
requestedSkillSlug: string | null = null,
|
||||
authToken?: string,
|
||||
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
||||
const url = sourceUrl.trim();
|
||||
const warnings: string[] = [];
|
||||
@@ -995,10 +998,11 @@ async function readUrlSkillImports(
|
||||
if (looksLikeRepoUrl) {
|
||||
const parsed = parseGitHubSourceUrl(url);
|
||||
const apiBase = gitHubApiBase(parsed.hostname);
|
||||
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
|
||||
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken);
|
||||
let ref = pinnedRef;
|
||||
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
||||
authToken,
|
||||
).catch(() => {
|
||||
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
||||
});
|
||||
@@ -1025,7 +1029,7 @@ async function readUrlSkillImports(
|
||||
const skills: ImportedSkill[] = [];
|
||||
for (const relativeSkillPath of skillPaths) {
|
||||
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
|
||||
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath));
|
||||
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken);
|
||||
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||
const skillDir = path.posix.dirname(relativeSkillPath);
|
||||
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
||||
@@ -1087,7 +1091,7 @@ async function readUrlSkillImports(
|
||||
}
|
||||
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
const markdown = await fetchText(url);
|
||||
const markdown = await fetchText(url, authToken);
|
||||
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||
const urlObj = new URL(url);
|
||||
const fileName = path.posix.basename(urlObj.pathname);
|
||||
@@ -1459,6 +1463,22 @@ export function companySkillService(db: Db) {
|
||||
const projects = projectService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
|
||||
/** Resolve the GitHub auth token from a skill's metadata, if stored. */
|
||||
async function resolveSkillAuthToken(
|
||||
companyId: string,
|
||||
skill: { metadata: Record<string, unknown> | null },
|
||||
): Promise<string | undefined> {
|
||||
const meta = skill.metadata;
|
||||
if (!meta) return undefined;
|
||||
const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : "";
|
||||
if (!secretId) return undefined;
|
||||
try {
|
||||
return await secretsSvc.resolveSecretValue(companyId, secretId, "latest");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBundledSkills(companyId: string) {
|
||||
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
||||
const stats = await fs.stat(skillsRoot).catch(() => null);
|
||||
@@ -1656,7 +1676,8 @@ export function companySkillService(db: Db) {
|
||||
|
||||
const hostname = asString(metadata.hostname) || "github.com";
|
||||
const apiBase = gitHubApiBase(hostname);
|
||||
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
|
||||
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken);
|
||||
return {
|
||||
supported: true,
|
||||
reason: null,
|
||||
@@ -1700,8 +1721,9 @@ export function companySkillService(db: Db) {
|
||||
if (!owner || !repo) {
|
||||
throw unprocessable("Skill source metadata is incomplete.");
|
||||
}
|
||||
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
|
||||
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
|
||||
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken);
|
||||
} else if (skill.sourceType === "url") {
|
||||
if (normalizedPath !== "SKILL.md") {
|
||||
throw notFound("This skill source only exposes SKILL.md");
|
||||
@@ -1818,7 +1840,8 @@ export function companySkillService(db: Db) {
|
||||
throw unprocessable("Skill source locator is missing.");
|
||||
}
|
||||
|
||||
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
|
||||
const authToken = await resolveSkillAuthToken(companyId, skill);
|
||||
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken);
|
||||
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
|
||||
if (!matching) {
|
||||
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
|
||||
@@ -1993,6 +2016,64 @@ export function companySkillService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-scan GitHub/sks_sh sources to pick up newly added skills and prune removed ones
|
||||
const sourceLocators = new Set<string>();
|
||||
for (const skill of acceptedSkills) {
|
||||
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") continue;
|
||||
const locator = skill.sourceLocator ?? "";
|
||||
if (locator) sourceLocators.add(locator);
|
||||
}
|
||||
for (const sourceLocator of sourceLocators) {
|
||||
try {
|
||||
const result = await readUrlSkillImports(companyId, sourceLocator, null);
|
||||
const currentSlugs = new Set(result.skills.map((s) => s.slug));
|
||||
|
||||
// Upsert any new skills found in the source
|
||||
for (const nextSkill of result.skills) {
|
||||
if (acceptedSkills.some((s) => s.slug === nextSkill.slug)) continue;
|
||||
const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0];
|
||||
if (persisted) {
|
||||
imported.push(persisted);
|
||||
upsertAcceptedSkill(persisted);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune skills that are no longer in the source
|
||||
const skillsAtSource = acceptedSkills.filter((s) => s.sourceLocator === sourceLocator);
|
||||
for (const skill of skillsAtSource) {
|
||||
if (currentSlugs.has(skill.slug)) continue;
|
||||
const usedByAgents = await usage(companyId, skill.key);
|
||||
if (usedByAgents.length > 0) {
|
||||
// Detach the skill from all agents that have it, then delete
|
||||
for (const agent of usedByAgents) {
|
||||
const fullAgent = await agents.getById(agent.id);
|
||||
if (!fullAgent) continue;
|
||||
const currentConfig = (fullAgent.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
const preference = readPaperclipSkillSyncPreference(currentConfig);
|
||||
if (preference.desiredSkills.includes(skill.key)) {
|
||||
const updatedConfig = writePaperclipSkillSyncPreference(
|
||||
currentConfig,
|
||||
preference.desiredSkills.filter((k) => k !== skill.key),
|
||||
);
|
||||
await agents.update(fullAgent.id, { adapterConfig: updatedConfig });
|
||||
}
|
||||
}
|
||||
warnings.push(
|
||||
`Skill "${skill.slug}" was removed from ${sourceLocator} and detached from ${usedByAgents.map((a) => a.name).join(", ")}.`,
|
||||
);
|
||||
} else {
|
||||
warnings.push(
|
||||
`Skill "${skill.slug}" was removed from ${sourceLocator} and deleted.`,
|
||||
);
|
||||
}
|
||||
await deleteSkill(companyId, skill.id);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: don't fail the whole scan if one source fails
|
||||
warnings.push(`Could not re-scan source ${sourceLocator} — skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scannedProjects: scannedProjectIds.size,
|
||||
scannedWorkspaces: scanTargets.length,
|
||||
@@ -2230,6 +2311,10 @@ export function companySkillService(db: Db) {
|
||||
const metadata = {
|
||||
...(skill.metadata ?? {}),
|
||||
skillKey: skill.key,
|
||||
// Preserve auth secret reference across re-imports/updates
|
||||
...(existing?.metadata && typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
|
||||
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
|
||||
: {}),
|
||||
};
|
||||
const values = {
|
||||
companyId,
|
||||
@@ -2265,7 +2350,7 @@ export function companySkillService(db: Db) {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
|
||||
async function importFromSource(companyId: string, source: string, authToken?: string): Promise<CompanySkillImportResult> {
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
const parsed = parseSkillImportSourceInput(source);
|
||||
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
||||
@@ -2275,7 +2360,7 @@ export function companySkillService(db: Db) {
|
||||
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
||||
warnings: parsed.warnings,
|
||||
}
|
||||
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
|
||||
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken)
|
||||
.then((result) => ({
|
||||
skills: result.skills,
|
||||
warnings: [...parsed.warnings, ...result.warnings],
|
||||
@@ -2302,6 +2387,35 @@ export function companySkillService(db: Db) {
|
||||
}
|
||||
}
|
||||
const imported = await upsertImportedSkills(companyId, filteredSkills);
|
||||
|
||||
// Store the auth token as an encrypted company secret and link to imported skills
|
||||
if (authToken && imported.length > 0) {
|
||||
for (const skill of imported) {
|
||||
const secretName = `skill-pat:${skill.id}`;
|
||||
let secretId: string;
|
||||
const existing = await secretsSvc.getByName(companyId, secretName);
|
||||
if (existing) {
|
||||
await secretsSvc.rotate(existing.id, { value: authToken });
|
||||
secretId = existing.id;
|
||||
} else {
|
||||
const created = await secretsSvc.create(companyId, {
|
||||
name: secretName,
|
||||
provider: "local_encrypted",
|
||||
value: authToken,
|
||||
description: `GitHub PAT for skill ${skill.slug}`,
|
||||
});
|
||||
secretId = created.id;
|
||||
}
|
||||
// Store the secret ID in skill metadata
|
||||
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
||||
meta.sourceAuthSecretId = secretId;
|
||||
await db
|
||||
.update(companySkills)
|
||||
.set({ metadata: meta, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId)));
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, warnings };
|
||||
}
|
||||
|
||||
@@ -2341,9 +2455,110 @@ export function companySkillService(db: Db) {
|
||||
// Clean up materialized runtime files
|
||||
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
|
||||
|
||||
// Delete associated PAT secret if present
|
||||
const meta = skill.metadata as Record<string, unknown> | null;
|
||||
const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
|
||||
if (secretId) {
|
||||
try {
|
||||
await secretsSvc.remove(secretId);
|
||||
} catch {
|
||||
// Best-effort: don't fail the skill deletion if secret cleanup fails
|
||||
}
|
||||
}
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
async function updateSkillAuth(
|
||||
companyId: string,
|
||||
skillId: string,
|
||||
authToken: string | null,
|
||||
): Promise<CompanySkill | null> {
|
||||
const skill = await getById(skillId);
|
||||
if (!skill || skill.companyId !== companyId) return null;
|
||||
|
||||
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
||||
const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
|
||||
|
||||
if (authToken) {
|
||||
// Set or update the PAT
|
||||
const secretName = `skill-pat:${skill.id}`;
|
||||
let secretId: string;
|
||||
// Check metadata reference first, then fall back to name lookup
|
||||
// (metadata ref may have been lost during a skill update/re-import)
|
||||
const existingSecret = existingSecretId
|
||||
? await secretsSvc.getById(existingSecretId)
|
||||
: await secretsSvc.getByName(companyId, secretName);
|
||||
if (existingSecret) {
|
||||
await secretsSvc.rotate(existingSecret.id, { value: authToken });
|
||||
secretId = existingSecret.id;
|
||||
} else {
|
||||
const created = await secretsSvc.create(companyId, {
|
||||
name: secretName,
|
||||
provider: "local_encrypted",
|
||||
value: authToken,
|
||||
description: `GitHub PAT for skill ${skill.slug}`,
|
||||
});
|
||||
secretId = created.id;
|
||||
}
|
||||
meta.sourceAuthSecretId = secretId;
|
||||
} else {
|
||||
// Clear the PAT — delete the secret row to avoid orphaned secrets
|
||||
if (existingSecretId) {
|
||||
try {
|
||||
await secretsSvc.remove(existingSecretId);
|
||||
} catch {
|
||||
// Best-effort: don't fail the metadata update if secret deletion fails
|
||||
}
|
||||
}
|
||||
delete meta.sourceAuthSecretId;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(companySkills)
|
||||
.set({ metadata: meta, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
|
||||
.returning();
|
||||
return updated ? toCompanySkill(updated) : null;
|
||||
}
|
||||
|
||||
async function deleteBySource(companyId: string, sourceLocator: string): Promise<CompanySkill[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(companySkills)
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator)));
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Pre-check all skills for agent usage before deleting any (atomicity)
|
||||
const skills = rows.map(toCompanySkill);
|
||||
for (const skill of skills) {
|
||||
const usedByAgents = await usage(companyId, skill.key);
|
||||
if (usedByAgents.length > 0) {
|
||||
const agentNames = usedByAgents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right));
|
||||
throw unprocessable(
|
||||
`Cannot delete skills from "${sourceLocator}" because skill "${skill.name}" is still used by ${agentNames.join(", ")}. Detach it from those agents first.`,
|
||||
{
|
||||
skillId: skill.id,
|
||||
skillKey: skill.key,
|
||||
usedByAgents: usedByAgents.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
urlKey: agent.urlKey,
|
||||
adapterType: agent.adapterType,
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const deleted: CompanySkill[] = [];
|
||||
for (const row of rows) {
|
||||
const result = await deleteSkill(companyId, row.id);
|
||||
if (result) deleted.push(result);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
listFull,
|
||||
@@ -2359,7 +2574,9 @@ export function companySkillService(db: Db) {
|
||||
updateFile,
|
||||
createLocalSkill,
|
||||
deleteSkill,
|
||||
deleteBySource,
|
||||
importFromSource,
|
||||
updateSkillAuth,
|
||||
scanProjectWorkspaces,
|
||||
importPackageFiles,
|
||||
installUpdate,
|
||||
|
||||
@@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin
|
||||
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
|
||||
}
|
||||
|
||||
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (authToken) {
|
||||
headers.set("Authorization", `Bearer ${authToken}`);
|
||||
}
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
return await fetch(url, { ...init, headers });
|
||||
} catch {
|
||||
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,23 @@ export const companySkillsApi = {
|
||||
`/companies/${encodeURIComponent(companyId)}/skills`,
|
||||
payload,
|
||||
),
|
||||
importFromSource: (companyId: string, source: string) =>
|
||||
importFromSource: (companyId: string, source: string, authToken?: string) =>
|
||||
api.post<CompanySkillImportResult>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/import`,
|
||||
{ source },
|
||||
{ source, ...(authToken ? { authToken } : {}) },
|
||||
),
|
||||
updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
|
||||
api.patch<CompanySkill>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
|
||||
{ authToken },
|
||||
),
|
||||
remove: (companyId: string, skillId: string) =>
|
||||
api.delete<CompanySkill>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
|
||||
),
|
||||
removeBySource: (companyId: string, sourceLocator: string) =>
|
||||
api.delete<{ deleted: CompanySkill[] }>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/by-source?source=${encodeURIComponent(sourceLocator)}`,
|
||||
),
|
||||
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
|
||||
api.post<CompanySkillProjectScanResult>(
|
||||
@@ -51,8 +64,4 @@ export const companySkillsApi = {
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,
|
||||
{},
|
||||
),
|
||||
delete: (companyId: string, skillId: string) =>
|
||||
api.delete<CompanySkill>(
|
||||
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
|
||||
),
|
||||
};
|
||||
|
||||
+187
-29
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type SVGProps } from "react";
|
||||
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
CompanySkill,
|
||||
CompanySkillCreateRequest,
|
||||
CompanySkillDetail,
|
||||
CompanySkillFileDetail,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -487,6 +489,103 @@ function SkillList({
|
||||
);
|
||||
}
|
||||
|
||||
function SkillAuthSection({
|
||||
companyId,
|
||||
skillId,
|
||||
hasAuth,
|
||||
}: {
|
||||
companyId: string;
|
||||
skillId: string;
|
||||
hasAuth: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [token, setToken] = useState("");
|
||||
|
||||
const updateAuth = useMutation({
|
||||
mutationFn: (authToken: string | null) =>
|
||||
companySkillsApi.updateAuth(companyId, skillId, authToken),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) });
|
||||
setEditing(false);
|
||||
setToken("");
|
||||
pushToast({ tone: "success", title: "Auth updated" });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Failed to update auth",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
|
||||
{!editing ? (
|
||||
<>
|
||||
{hasAuth ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
|
||||
PAT configured
|
||||
</Button>
|
||||
<button
|
||||
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
onClick={() => updateAuth.mutate(null)}
|
||||
disabled={updateAuth.isPending}
|
||||
title="Remove PAT"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Add PAT
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="GitHub Personal Access Token"
|
||||
className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateAuth.mutate(token.trim())}
|
||||
disabled={!token.trim() || updateAuth.isPending}
|
||||
>
|
||||
{updateAuth.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setEditing(false); setToken(""); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillPane({
|
||||
loading,
|
||||
detail,
|
||||
@@ -525,7 +624,7 @@ function SkillPane({
|
||||
checkUpdatesPending: boolean;
|
||||
onInstallUpdate: () => void;
|
||||
installUpdatePending: boolean;
|
||||
onDelete: () => void;
|
||||
onDelete: (sourceLocator?: string | null) => void;
|
||||
deletePending: boolean;
|
||||
onSave: () => void;
|
||||
savePending: boolean;
|
||||
@@ -572,7 +671,7 @@ function SkillPane({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
onClick={() => onDelete()}
|
||||
disabled={deletePending}
|
||||
title={removeDisabledReason ?? undefined}
|
||||
>
|
||||
@@ -612,8 +711,23 @@ function SkillPane({
|
||||
) : (
|
||||
<span className="truncate">{source.label}</span>
|
||||
)}
|
||||
<button
|
||||
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
|
||||
onClick={() => onDelete(detail.sourceLocator)}
|
||||
disabled={deletePending}
|
||||
title={detail.sourceLocator ? "Remove this source and all its skills" : "Remove this skill"}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
|
||||
<SkillAuthSection
|
||||
companyId={detail.companyId}
|
||||
skillId={detail.id}
|
||||
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
|
||||
/>
|
||||
)}
|
||||
{detail.sourceType === "github" && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
|
||||
@@ -762,6 +876,7 @@ export function CompanySkills() {
|
||||
const { pushToast } = useToast();
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [importAuthToken, setImportAuthToken] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
|
||||
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
|
||||
@@ -775,6 +890,7 @@ export function CompanySkills() {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteTargetSkillId, setDeleteTargetSkillId] = useState<string | null>(null);
|
||||
const [deleteTargetDetail, setDeleteTargetDetail] = useState<CompanySkillDetail | null>(null);
|
||||
const [deleteTargetSourceLocator, setDeleteTargetSourceLocator] = useState<string | null>(null);
|
||||
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
|
||||
const routeSkillId = parsedRoute.skillId;
|
||||
const selectedPath = parsedRoute.filePath;
|
||||
@@ -883,11 +999,24 @@ export function CompanySkills() {
|
||||
if (!open) {
|
||||
setDeleteTargetSkillId(null);
|
||||
setDeleteTargetDetail(null);
|
||||
setDeleteTargetSourceLocator(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteSkill(sourceLocator?: string | null) {
|
||||
if (sourceLocator) {
|
||||
setDeleteTargetSourceLocator(sourceLocator);
|
||||
setDeleteTargetSkillId(null);
|
||||
setDeleteTargetDetail(null);
|
||||
setDeleteOpen(true);
|
||||
} else {
|
||||
openDeleteDialog();
|
||||
}
|
||||
}
|
||||
|
||||
const importSkill = useMutation({
|
||||
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
|
||||
mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) =>
|
||||
companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken),
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
|
||||
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
|
||||
@@ -900,6 +1029,7 @@ export function CompanySkills() {
|
||||
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
|
||||
}
|
||||
setSource("");
|
||||
setImportAuthToken("");
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
@@ -1026,8 +1156,13 @@ export function CompanySkills() {
|
||||
});
|
||||
|
||||
const deleteSkill = useMutation({
|
||||
mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!),
|
||||
onSuccess: async (skill) => {
|
||||
mutationFn: (sourceLocator?: string | null): Promise<{ deleted: CompanySkill[] }> => {
|
||||
if (sourceLocator) {
|
||||
return companySkillsApi.removeBySource(selectedCompanyId!, sourceLocator);
|
||||
}
|
||||
return companySkillsApi.remove(selectedCompanyId!, deleteTargetSkillId!).then((skill) => ({ deleted: [skill] }));
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
closeDeleteDialog(false);
|
||||
setDisplayedDetail(null);
|
||||
setDisplayedFile(null);
|
||||
@@ -1048,10 +1183,10 @@ export function CompanySkills() {
|
||||
type: "active",
|
||||
});
|
||||
navigate("/skills", { replace: true });
|
||||
const count = result.deleted.length;
|
||||
pushToast({
|
||||
tone: "success",
|
||||
title: "Skill removed",
|
||||
body: `${skill.name} was removed from the company skill library.`,
|
||||
title: `${count} skill${count === 1 ? "" : "s"} removed`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -1073,7 +1208,8 @@ export function CompanySkills() {
|
||||
setEmptySourceHelpOpen(true);
|
||||
return;
|
||||
}
|
||||
importSkill.mutate(trimmedSource);
|
||||
const token = importAuthToken.trim() || undefined;
|
||||
importSkill.mutate({ importSource: trimmedSource, authToken: token });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1081,30 +1217,40 @@ export function CompanySkills() {
|
||||
<Dialog open={deleteOpen} onOpenChange={closeDeleteDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove skill</DialogTitle>
|
||||
<DialogTitle>{deleteTargetSourceLocator ? "Remove skills from source" : "Remove skill"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached.
|
||||
{deleteTargetSourceLocator
|
||||
? `All skills imported from this source will be permanently removed from the company library.`
|
||||
: "Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
{deleteTargetDetail
|
||||
? `You are about to remove ${deleteTargetDetail.name}.`
|
||||
: "You are about to remove this skill."}
|
||||
</p>
|
||||
{deleteTargetDetail?.usedByAgents?.length ? (
|
||||
<div className="rounded-md border border-border px-3 py-3 text-muted-foreground">
|
||||
Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}.
|
||||
</div>
|
||||
) : null}
|
||||
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
Detach this skill from all agents to enable removal.
|
||||
{deleteTargetSourceLocator ? (
|
||||
<p className="rounded-md border border-destructive/50 bg-destructive/5 px-3 py-2 font-mono text-xs break-all">
|
||||
{deleteTargetSourceLocator}
|
||||
</p>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{deleteTargetDetail
|
||||
? `You are about to remove ${deleteTargetDetail.name}.`
|
||||
: "You are about to remove this skill."}
|
||||
</p>
|
||||
{deleteTargetDetail?.usedByAgents?.length ? (
|
||||
<div className="rounded-md border border-border px-3 py-3 text-muted-foreground">
|
||||
Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}.
|
||||
</div>
|
||||
) : null}
|
||||
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
Detach this skill from all agents to enable removal.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
|
||||
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 && !deleteTargetSourceLocator ? (
|
||||
<Button variant="ghost" onClick={() => closeDeleteDialog(false)}>
|
||||
Close
|
||||
</Button>
|
||||
@@ -1115,10 +1261,10 @@ export function CompanySkills() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteSkill.mutate()}
|
||||
disabled={deleteSkill.isPending || !deleteTargetSkillId}
|
||||
onClick={() => deleteSkill.mutate(deleteTargetSourceLocator ?? undefined)}
|
||||
disabled={deleteSkill.isPending || (!deleteTargetSkillId && !deleteTargetSourceLocator)}
|
||||
>
|
||||
{deleteSkill.isPending ? "Removing..." : "Remove skill"}
|
||||
{deleteSkill.isPending ? "Removing..." : deleteTargetSourceLocator ? "Remove all from source" : "Remove skill"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -1220,6 +1366,18 @@ export function CompanySkills() {
|
||||
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
{source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && (
|
||||
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
|
||||
<input
|
||||
type="password"
|
||||
value={importAuthToken}
|
||||
onChange={(event) => setImportAuthToken(event.target.value)}
|
||||
placeholder="GitHub PAT (optional, for private repos)"
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{scanStatusMessage && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{scanStatusMessage}
|
||||
@@ -1284,7 +1442,7 @@ export function CompanySkills() {
|
||||
checkUpdatesPending={updateStatusQuery.isFetching}
|
||||
onInstallUpdate={() => installUpdate.mutate()}
|
||||
installUpdatePending={installUpdate.isPending}
|
||||
onDelete={openDeleteDialog}
|
||||
onDelete={handleDeleteSkill}
|
||||
deletePending={deleteSkill.isPending}
|
||||
onSave={() => saveFile.mutate()}
|
||||
savePending={saveFile.isPending}
|
||||
|
||||
Reference in New Issue
Block a user