Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67fb8249ac | |||
| 59dc05bdbc | |||
| 33ab4f8cdd | |||
| 044d730525 | |||
| e559218f98 | |||
| 9f3f71a199 | |||
| 1a1c57461f |
@@ -0,0 +1,77 @@
|
|||||||
|
name: "Build: Dev"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
outputs:
|
||||||
|
image-tag: ${{ steps.tag.outputs.sha }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set image tag
|
||||||
|
id: tag
|
||||||
|
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.farh.net
|
||||||
|
username: admin
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.farh.net/farhoodlabs/paperclip-dev
|
||||||
|
tags: |
|
||||||
|
type=sha,prefix=
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
no-cache: true
|
||||||
|
|
||||||
|
update-infra:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Update dev image tag in infra repo
|
||||||
|
run: |
|
||||||
|
SHA="${{ needs.build.outputs.image-tag }}"
|
||||||
|
FILE="overlays/dev/kustomization.yaml"
|
||||||
|
|
||||||
|
response=$(curl -sS \
|
||||||
|
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE")
|
||||||
|
|
||||||
|
file_sha=$(echo "$response" | jq -r '.sha')
|
||||||
|
content=$(echo "$response" | jq -r '.content' | base64 -d)
|
||||||
|
new_content=$(echo "$content" | sed "s/newTag: \".*\"/newTag: \"$SHA\"/")
|
||||||
|
encoded=$(printf '%s' "$new_content" | base64 -w 0)
|
||||||
|
|
||||||
|
curl -sS -X PUT \
|
||||||
|
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
"https://git.farh.net/api/v1/repos/farhoodlabs/paperclip-infra/contents/$FILE" \
|
||||||
|
-d "{\"message\":\"chore(cd): update paperclip-dev to $SHA\",\"content\":\"$encoded\",\"sha\":\"$file_sha\"}"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: "Build: Production"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [local]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.farh.net
|
||||||
|
username: admin
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.farh.net/farhoodlabs/paperclip
|
||||||
|
tags: |
|
||||||
|
type=sha,prefix=
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(gitea.ref, 'refs/tags/v') }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
no-cache: true
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Paperclip fork — farhoodlabs
|
||||||
|
|
||||||
|
This is a thin fork of [paperclipai/paperclip](https://github.com/paperclipai/paperclip).
|
||||||
|
Fork repo: https://git.farh.net/farhoodlabs/paperclip
|
||||||
|
|
||||||
|
## Branch model
|
||||||
|
|
||||||
|
| Branch | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `master` | Pure mirror of `upstream/master`. No fork files. Sync via `git push origin upstream/master:master --force-with-lease`. |
|
||||||
|
| `dev` | `master` + one fork commit (Dockerfile prod stage + 2 build workflows). Builds `git.farh.net/farhoodlabs/paperclip-dev:*` on push. |
|
||||||
|
| `local` | **Deployed branch.** Same content as `dev`. Builds `git.farh.net/farhoodlabs/paperclip:*` on push. |
|
||||||
|
|
||||||
|
The fork tree differs from upstream by exactly **3 files**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Dockerfile (production stage adds kubectl, kubeseal, uv, forgejo CLIs, tea, mmx-cli, nano, vim)
|
||||||
|
.github/workflows/build-prod.yml (pushes to git.farh.net/farhoodlabs/paperclip)
|
||||||
|
.github/workflows/build-dev.yml (pushes to git.farh.net/farhoodlabs/paperclip-dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
The base/deps/build stages of the Dockerfile match upstream verbatim so upstream changes apply cleanly.
|
||||||
|
|
||||||
|
## Sync upstream
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git push origin upstream/master:master --force-with-lease
|
||||||
|
git checkout dev && git merge master && git push origin dev
|
||||||
|
git checkout local && git merge dev && git push origin local
|
||||||
|
```
|
||||||
|
|
||||||
|
Conflicts should only ever appear on `Dockerfile` itself (if upstream changes the production stage). Resolution rule: keep upstream's deps/base/build stages exactly; preserve the fork's `RUN` block in the production stage.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Production runs in Kubernetes (`paperclip` namespace, single replica). Image: `git.farh.net/farhoodlabs/paperclip:<tag>`. Flux does not watch moving tags — rolling a fix means either pushing a semver-tagged release or `kubectl rollout restart deploy/paperclip -n paperclip`.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
|
||||||
|
- **Don't add fork code changes.** This fork is intentionally minimal after the 2026-05-31 reset (event-loop starvation bug from accumulated drift). If a feature is missing relative to a prior fork iteration (Gitea-hosted skills, PAT support for private skill repos, secret export/import, k8s sandbox-provider plugin, agentId threading), surface the regression — don't pull it back from `git log` without explicit go-ahead.
|
||||||
|
- **Don't commit to `local` without going through `dev` first** (and through `master` for upstream syncs). The promotion order is enforced.
|
||||||
|
- **Don't recreate `.farhoodlabs/` overlay or `assemble-local.yml`.** That model was retired.
|
||||||
+22
-3
@@ -57,10 +57,29 @@ ARG USER_UID=1000
|
|||||||
ARG USER_GID=1000
|
ARG USER_GID=1000
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --chown=node:node --from=build /app /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 \
|
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools, mmx-cli
|
||||||
&& apt-get update \
|
# Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq
|
||||||
&& apt-get install -y --no-install-recommends openssh-client jq \
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends openssh-client jq nano vim \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& 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 \
|
||||||
|
&& curl -fsSL https://codeberg.org/forgejo-contrib/forgejo-cli/releases/download/v0.4.1/forgejo-cli-linux.tar.gz | tar -xzf - -C /usr/local/bin \
|
||||||
|
&& chmod +x /usr/local/bin/fj \
|
||||||
|
&& curl -fsSL https://github.com/JKamsker/forgejo-cli-ex/releases/download/v0.1.7/fj-ex-linux-x86_64.tar.gz | tar -xzf - -C /usr/local/bin \
|
||||||
|
&& chmod +x /usr/local/bin/fj-ex \
|
||||||
|
&& curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \
|
||||||
|
&& chmod +x /usr/local/bin/fgj \
|
||||||
|
&& curl -fsSL https://dl.gitea.com/tea/0.14.0/tea-0.14.0-linux-amd64 -o /usr/local/bin/tea \
|
||||||
|
&& chmod +x /usr/local/bin/tea \
|
||||||
|
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||||
|
&& npm install --global --omit=dev mmx-cli \
|
||||||
&& mkdir -p /paperclip \
|
&& mkdir -p /paperclip \
|
||||||
&& chown node:node /paperclip
|
&& chown node:node /paperclip
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export function registerSkillsCommands(program: Command): void {
|
|||||||
addCommonClientOptions(
|
addCommonClientOptions(
|
||||||
skills
|
skills
|
||||||
.command("import")
|
.command("import")
|
||||||
.description("Import company skills from a local path, GitHub, skills.sh, or URL source")
|
.description("Import company skills from a local path, GitHub, Gitea/Forgejo, skills.sh, or URL source")
|
||||||
.argument("<source>", "Skill source")
|
.argument("<source>", "Skill source")
|
||||||
.action(async (source: string, opts: SkillsOptions) => {
|
.action(async (source: string, opts: SkillsOptions) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog" | "skills_sh";
|
export type CompanySkillSourceType = "local_path" | "github" | "gitea" | "url" | "catalog" | "skills_sh";
|
||||||
|
|
||||||
export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables";
|
export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables";
|
||||||
|
|
||||||
export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid";
|
export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid";
|
||||||
|
|
||||||
export type CompanySkillSourceBadge = "paperclip" | "github" | "local" | "url" | "catalog" | "skills_sh";
|
export type CompanySkillSourceBadge = "paperclip" | "github" | "gitea" | "local" | "url" | "catalog" | "skills_sh";
|
||||||
|
|
||||||
export interface CompanySkillFileInventoryEntry {
|
export interface CompanySkillFileInventoryEntry {
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog", "skills_sh"]);
|
export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "gitea", "url", "catalog", "skills_sh"]);
|
||||||
export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]);
|
export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]);
|
||||||
export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]);
|
export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]);
|
||||||
export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog", "skills_sh"]);
|
export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "gitea", "local", "url", "catalog", "skills_sh"]);
|
||||||
|
|
||||||
export const companySkillFileInventoryEntrySchema = z.object({
|
export const companySkillFileInventoryEntrySchema = z.object({
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
|
|||||||
@@ -444,6 +444,53 @@ describe("company skill mutation permissions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports Gitea skills and tracks the import with skillRef null (non-github.com)", async () => {
|
||||||
|
mockCompanySkillService.importFromSource.mockResolvedValue({
|
||||||
|
imported: [
|
||||||
|
{
|
||||||
|
id: "skill-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
key: "git.example.com/acme/remote-skill/web",
|
||||||
|
slug: "web",
|
||||||
|
name: "Remote Skill",
|
||||||
|
description: null,
|
||||||
|
markdown: "# Remote",
|
||||||
|
sourceType: "gitea",
|
||||||
|
sourceLocator: "https://git.example.com/acme/remote-skill",
|
||||||
|
sourceRef: "abc123abc123abc123abc123abc123abc123abcd",
|
||||||
|
trustLevel: "markdown_only",
|
||||||
|
compatibility: "compatible",
|
||||||
|
fileInventory: [],
|
||||||
|
metadata: {
|
||||||
|
hostname: "git.example.com",
|
||||||
|
owner: "acme",
|
||||||
|
repo: "remote-skill",
|
||||||
|
sourceKind: "gitea",
|
||||||
|
},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
}))
|
||||||
|
.post("/api/companies/company-1/skills/import")
|
||||||
|
.send({ source: "https://git.example.com/acme/remote-skill" });
|
||||||
|
|
||||||
|
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||||
|
expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), {
|
||||||
|
sourceType: "gitea",
|
||||||
|
skillRef: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("blocks same-company agents without management permission from mutating company skills", async () => {
|
it("blocks same-company agents without management permission from mutating company skills", async () => {
|
||||||
mockAgentService.getById.mockResolvedValue({
|
mockAgentService.getById.mockResolvedValue({
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
|
|||||||
@@ -143,6 +143,41 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not persist audit failures for gitea-source skills", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const skillId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(companySkills).values({
|
||||||
|
id: skillId,
|
||||||
|
companyId,
|
||||||
|
key: "git.example.com/acme/remote-skill",
|
||||||
|
slug: "remote-skill",
|
||||||
|
name: "Remote Skill",
|
||||||
|
description: null,
|
||||||
|
markdown: "# Remote Skill\n",
|
||||||
|
sourceType: "gitea",
|
||||||
|
sourceLocator: "https://git.example.com/acme/remote-skill",
|
||||||
|
sourceRef: "main",
|
||||||
|
trustLevel: "markdown_only",
|
||||||
|
compatibility: "compatible",
|
||||||
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||||
|
metadata: { sourceKind: "gitea", hostname: "git.example.com", owner: "acme", repo: "remote-skill" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(svc.auditSkill(companyId, skillId)).rejects.toMatchObject({
|
||||||
|
status: 422,
|
||||||
|
message: "Only local-path and catalog-managed company skills support audit.",
|
||||||
|
});
|
||||||
|
await expect(svc.getById(companyId, skillId)).resolves.toMatchObject({
|
||||||
|
metadata: { sourceKind: "gitea", owner: "acme", repo: "remote-skill" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves missing local-path skills that active agents still desire", async () => {
|
it("preserves missing local-path skills that active agents still desire", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const skillId = randomUUID();
|
const skillId = randomUUID();
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
fetchGiteaBranch,
|
||||||
|
fetchGiteaText,
|
||||||
|
fetchGiteaTreeBlobPaths,
|
||||||
|
giteaApiBase,
|
||||||
|
giteaHostProbeCache,
|
||||||
|
isPrivateOrLoopbackHost,
|
||||||
|
parseGiteaSourceUrl,
|
||||||
|
probeGiteaHost,
|
||||||
|
resolveGiteaPinnedRef,
|
||||||
|
resolveRawGiteaUrl,
|
||||||
|
resolveRawGiteaUrlLegacy,
|
||||||
|
setGiteaHostProbe,
|
||||||
|
} from "../services/gitea-skills.js";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, init?: { status?: number; ok?: boolean }) {
|
||||||
|
const status = init?.status ?? 200;
|
||||||
|
return {
|
||||||
|
ok: init?.ok ?? (status >= 200 && status < 300),
|
||||||
|
status,
|
||||||
|
statusText: status === 200 ? "OK" : "Error",
|
||||||
|
text: () => Promise.resolve(JSON.stringify(body)),
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResponse(body: string, init?: { status?: number; ok?: boolean }) {
|
||||||
|
const status = init?.status ?? 200;
|
||||||
|
return {
|
||||||
|
ok: init?.ok ?? (status >= 200 && status < 300),
|
||||||
|
status,
|
||||||
|
statusText: status === 200 ? "OK" : "Error",
|
||||||
|
text: () => Promise.resolve(body),
|
||||||
|
json: () => Promise.reject(new Error("not json")),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("giteaApiBase", () => {
|
||||||
|
it("returns the Gitea API base", () => {
|
||||||
|
expect(giteaApiBase("git.example.com")).toBe("https://git.example.com/api/v1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRawGiteaUrl", () => {
|
||||||
|
it("builds the modern /raw/branch/ URL", () => {
|
||||||
|
expect(resolveRawGiteaUrl("git.example.com", "acme", "skills", "main", "foo/SKILL.md"))
|
||||||
|
.toBe("https://git.example.com/acme/skills/raw/branch/main/foo/SKILL.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips leading slashes from the file path", () => {
|
||||||
|
expect(resolveRawGiteaUrl("git.example.com", "acme", "skills", "abc123", "/nested/file.md"))
|
||||||
|
.toBe("https://git.example.com/acme/skills/raw/branch/abc123/nested/file.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRawGiteaUrlLegacy", () => {
|
||||||
|
it("builds the legacy /raw/ URL", () => {
|
||||||
|
expect(resolveRawGiteaUrlLegacy("git.example.com", "acme", "skills", "main", "SKILL.md"))
|
||||||
|
.toBe("https://git.example.com/acme/skills/raw/main/SKILL.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseGiteaSourceUrl", () => {
|
||||||
|
it("parses a plain repo URL", () => {
|
||||||
|
expect(parseGiteaSourceUrl("https://git.example.com/acme/skills")).toEqual({
|
||||||
|
hostname: "git.example.com",
|
||||||
|
owner: "acme",
|
||||||
|
repo: "skills",
|
||||||
|
ref: "main",
|
||||||
|
basePath: "",
|
||||||
|
filePath: null,
|
||||||
|
explicitRef: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips .git suffix", () => {
|
||||||
|
expect(parseGiteaSourceUrl("https://git.example.com/acme/skills.git").repo).toBe("skills");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a /tree/{ref}/{basePath} URL", () => {
|
||||||
|
expect(parseGiteaSourceUrl("https://git.example.com/acme/skills/tree/dev/skills/web")).toEqual({
|
||||||
|
hostname: "git.example.com",
|
||||||
|
owner: "acme",
|
||||||
|
repo: "skills",
|
||||||
|
ref: "dev",
|
||||||
|
basePath: "skills/web",
|
||||||
|
filePath: null,
|
||||||
|
explicitRef: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a /blob/{ref}/{filePath} URL", () => {
|
||||||
|
expect(parseGiteaSourceUrl("https://git.example.com/acme/skills/blob/main/SKILL.md")).toEqual({
|
||||||
|
hostname: "git.example.com",
|
||||||
|
owner: "acme",
|
||||||
|
repo: "skills",
|
||||||
|
ref: "main",
|
||||||
|
basePath: ".",
|
||||||
|
filePath: "SKILL.md",
|
||||||
|
explicitRef: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-HTTPS URLs", () => {
|
||||||
|
expect(() => parseGiteaSourceUrl("http://git.example.com/acme/skills")).toThrow(/HTTPS/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects URLs with fewer than 2 path segments", () => {
|
||||||
|
expect(() => parseGiteaSourceUrl("https://git.example.com/acme")).toThrow(/Invalid Gitea URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects URLs with empty repo after .git strip", () => {
|
||||||
|
expect(() => parseGiteaSourceUrl("https://git.example.com/acme/.git")).toThrow(
|
||||||
|
/owner and repo are required/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects URLs pointing at private/loopback hosts", () => {
|
||||||
|
expect(() => parseGiteaSourceUrl("https://192.168.1.10/acme/skills")).toThrow(
|
||||||
|
/private, loopback/,
|
||||||
|
);
|
||||||
|
expect(() => parseGiteaSourceUrl("https://localhost/acme/skills")).toThrow(
|
||||||
|
/private, loopback/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isPrivateOrLoopbackHost", () => {
|
||||||
|
it("flags loopback and localhost variants", () => {
|
||||||
|
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("127.99.99.99")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("::1")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("foo.localhost")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags RFC1918 ranges", () => {
|
||||||
|
expect(isPrivateOrLoopbackHost("10.0.0.1")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("172.16.0.1")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("172.31.255.254")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("192.168.1.1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags link-local and 0.0.0.0", () => {
|
||||||
|
expect(isPrivateOrLoopbackHost("169.254.169.254")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("0.0.0.0")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("fe80::1")).toBe(true);
|
||||||
|
expect(isPrivateOrLoopbackHost("fd00::1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows public hosts", () => {
|
||||||
|
expect(isPrivateOrLoopbackHost("git.example.com")).toBe(false);
|
||||||
|
expect(isPrivateOrLoopbackHost("gitea.com")).toBe(false);
|
||||||
|
expect(isPrivateOrLoopbackHost("172.32.0.1")).toBe(false);
|
||||||
|
expect(isPrivateOrLoopbackHost("11.0.0.1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("probeGiteaHost", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
giteaHostProbeCache.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when /api/v1/version returns Gitea-shaped JSON", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(jsonResponse({ version: "1.21.0" })),
|
||||||
|
);
|
||||||
|
const result = await probeGiteaHost("git.example.com");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on 200 with non-JSON body", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: () => Promise.resolve("<html>not gitea</html>"),
|
||||||
|
json: () => Promise.reject(new Error("not json")),
|
||||||
|
} as unknown as Response),
|
||||||
|
);
|
||||||
|
const result = await probeGiteaHost("git.example.com");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on 404", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(jsonResponse({}, { status: 404, ok: false })),
|
||||||
|
);
|
||||||
|
const result = await probeGiteaHost("git.example.com");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on network error", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
||||||
|
const result = await probeGiteaHost("git.example.com");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches positive and negative results", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ version: "1.21.0" }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
await probeGiteaHost("git.example.com");
|
||||||
|
await probeGiteaHost("git.example.com");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses a manually-set cache entry without hitting fetch", async () => {
|
||||||
|
setGiteaHostProbe("git.example.com", true);
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const result = await probeGiteaHost("git.example.com");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits to false for private/loopback hosts without making a request", async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
expect(await probeGiteaHost("127.0.0.1")).toBe(false);
|
||||||
|
expect(await probeGiteaHost("192.168.1.1")).toBe(false);
|
||||||
|
expect(await probeGiteaHost("localhost")).toBe(false);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveGiteaPinnedRef", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
giteaHostProbeCache.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the 40-hex ref as the pinned ref", async () => {
|
||||||
|
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
const result = await resolveGiteaPinnedRef(
|
||||||
|
parseGiteaSourceUrl(`https://git.example.com/acme/skills/tree/${sha}`),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ pinnedRef: sha, trackingRef: sha });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a branch ref to its commit SHA via the branches endpoint", async () => {
|
||||||
|
const branchSha = "fedcba9876543210fedcba9876543210fedcba98";
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ default_branch: "main" }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ name: "main", commit: { id: branchSha } }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const result = await resolveGiteaPinnedRef(
|
||||||
|
parseGiteaSourceUrl("https://git.example.com/acme/skills"),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ pinnedRef: branchSha, trackingRef: "main" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchGiteaTreeBlobPaths", () => {
|
||||||
|
it("returns blob paths from a single-page tree", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
tree: [
|
||||||
|
{ path: "README.md", type: "blob" },
|
||||||
|
{ path: "skills", type: "tree" },
|
||||||
|
{ path: "skills/web/SKILL.md", type: "blob" },
|
||||||
|
{ path: "skills/cli/SKILL.md", type: "blob" },
|
||||||
|
],
|
||||||
|
truncated: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const paths = await fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main");
|
||||||
|
expect(paths).toEqual(["README.md", "skills/web/SKILL.md", "skills/cli/SKILL.md"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates when the tree is truncated", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
tree: [{ path: "a.md", type: "blob" }],
|
||||||
|
truncated: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
tree: [{ path: "b.md", type: "blob" }],
|
||||||
|
truncated: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const paths = await fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main");
|
||||||
|
expect(paths).toEqual(["a.md", "b.md"]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("page=2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the page cap is hit while the tree is still truncated", async () => {
|
||||||
|
// Return truncated=true on every page so the loop hits MAX_PAGES (50).
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
tree: [{ path: "page.md", type: "blob" }],
|
||||||
|
truncated: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main"),
|
||||||
|
).rejects.toThrow(/exceeds .* entries/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchGiteaText", () => {
|
||||||
|
it("returns the body from the canonical URL on 200", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(textResponse("---\nname: x\n---\n# body\n")));
|
||||||
|
const content = await fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md");
|
||||||
|
expect(content).toContain("# body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the legacy /raw/ URL on 404", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(textResponse("", { status: 404, ok: false }))
|
||||||
|
.mockResolvedValueOnce(textResponse("legacy body"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const content = await fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md");
|
||||||
|
expect(content).toBe("legacy body");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/raw/branch/");
|
||||||
|
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/raw/main/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws unprocessable when both URLs fail with non-404", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(textResponse("", { status: 500, ok: false }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
await expect(
|
||||||
|
fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md"),
|
||||||
|
).rejects.toThrow(/500/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchGiteaBranch", () => {
|
||||||
|
it("returns the branch response", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse({ name: "main", commit: { id: "deadbeef".repeat(5) } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const branch = await fetchGiteaBranch(giteaApiBase("git.example.com"), "acme", "skills", "main");
|
||||||
|
expect(branch.commit?.id).toBe("deadbeef".repeat(5));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
@@ -59,7 +59,7 @@ function mermaidEscape(s: string): string {
|
|||||||
function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string {
|
function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string {
|
||||||
if (skill.sourceLocator) {
|
if (skill.sourceLocator) {
|
||||||
// For GitHub or URL sources, render as a markdown link
|
// For GitHub or URL sources, render as a markdown link
|
||||||
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url") {
|
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "gitea" || skill.sourceType === "url") {
|
||||||
return `[${skill.sourceType}](${skill.sourceLocator})`;
|
return `[${skill.sourceType}](${skill.sourceLocator})`;
|
||||||
}
|
}
|
||||||
return skill.sourceLocator;
|
return skill.sourceLocator;
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ function deriveManifestSkillKey(
|
|||||||
const sourceKind = asString(metadata?.sourceKind);
|
const sourceKind = asString(metadata?.sourceKind);
|
||||||
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
||||||
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
||||||
if ((sourceType === "github" || sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) {
|
if ((sourceType === "github" || sourceType === "skills_sh" || sourceType === "gitea" || sourceKind === "github" || sourceKind === "skills_sh" || sourceKind === "gitea") && owner && repo) {
|
||||||
return `${owner}/${repo}/${slug}`;
|
return `${owner}/${repo}/${slug}`;
|
||||||
}
|
}
|
||||||
if (sourceKind === "paperclip_bundled") {
|
if (sourceKind === "paperclip_bundled") {
|
||||||
@@ -345,10 +345,10 @@ function deriveSkillExportDirCandidates(
|
|||||||
pushSuffix("paperclip");
|
pushSuffix("paperclip");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
|
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "gitea") {
|
||||||
pushSuffix(asString(metadata?.repo));
|
pushSuffix(asString(metadata?.repo));
|
||||||
pushSuffix(asString(metadata?.owner));
|
pushSuffix(asString(metadata?.owner));
|
||||||
pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : "github");
|
pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : skill.sourceType === "gitea" ? "gitea" : "github");
|
||||||
} else if (skill.sourceType === "url") {
|
} else if (skill.sourceType === "url") {
|
||||||
try {
|
try {
|
||||||
pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null);
|
pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null);
|
||||||
@@ -2109,7 +2109,7 @@ function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkill
|
|||||||
if (expandReferencedSkills) return false;
|
if (expandReferencedSkills) return false;
|
||||||
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
|
||||||
if (asString(metadata?.sourceKind) === "paperclip_bundled") return true;
|
if (asString(metadata?.sourceKind) === "paperclip_bundled") return true;
|
||||||
return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url";
|
return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "gitea" || skill.sourceType === "url";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
|||||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
|
||||||
|
import { giteaApiBase } from "./gitea-fetch.js";
|
||||||
|
import {
|
||||||
|
fetchGiteaBranch,
|
||||||
|
fetchGiteaText,
|
||||||
|
fetchGiteaTreeBlobPaths,
|
||||||
|
parseGiteaSourceUrl,
|
||||||
|
probeGiteaHost,
|
||||||
|
resolveGiteaPinnedRef,
|
||||||
|
} from "./gitea-skills.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
import { projectService } from "./projects.js";
|
import { projectService } from "./projects.js";
|
||||||
import { normalizePortablePath } from "./portable-path.js";
|
import { normalizePortablePath } from "./portable-path.js";
|
||||||
@@ -389,7 +398,16 @@ function deriveCanonicalSkillKey(
|
|||||||
|
|
||||||
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
||||||
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
||||||
if ((input.sourceType === "github" || input.sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) {
|
if (
|
||||||
|
(input.sourceType === "github"
|
||||||
|
|| input.sourceType === "skills_sh"
|
||||||
|
|| input.sourceType === "gitea"
|
||||||
|
|| sourceKind === "github"
|
||||||
|
|| sourceKind === "skills_sh"
|
||||||
|
|| sourceKind === "gitea")
|
||||||
|
&& owner
|
||||||
|
&& repo
|
||||||
|
) {
|
||||||
return `${owner}/${repo}/${slug}`;
|
return `${owner}/${repo}/${slug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,97 +1142,15 @@ async function readUrlSkillImports(
|
|||||||
return segments.length >= 2 && !parsed.pathname.endsWith(".md");
|
return segments.length >= 2 && !parsed.pathname.endsWith(".md");
|
||||||
} catch { return false; } })();
|
} catch { return false; } })();
|
||||||
if (looksLikeRepoUrl) {
|
if (looksLikeRepoUrl) {
|
||||||
const parsed = parseGitHubSourceUrl(url);
|
const repoHost = new URL(url).hostname.toLowerCase();
|
||||||
const apiBase = gitHubApiBase(parsed.hostname);
|
const isGitHubDotCom = repoHost === "github.com" || repoHost === "www.github.com";
|
||||||
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
|
// Only probe non-github.com hosts. Unknown hosts that aren't Gitea (e.g.
|
||||||
let ref = pinnedRef;
|
// GitHub Enterprise) fall through to the GitHub path, which handles
|
||||||
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
// arbitrary hostnames via parseGitHubSourceUrl.
|
||||||
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
if (!isGitHubDotCom && (await probeGiteaHost(repoHost))) {
|
||||||
).catch(() => {
|
return await readGiteaUrlSkillImports(companyId, sourceUrl, requestedSkillSlug);
|
||||||
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
|
||||||
});
|
|
||||||
const allPaths = (tree.tree ?? [])
|
|
||||||
.filter((entry) => entry.type === "blob")
|
|
||||||
.map((entry) => entry.path)
|
|
||||||
.filter((entry): entry is string => typeof entry === "string");
|
|
||||||
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
|
|
||||||
const scopedPaths = basePrefix
|
|
||||||
? allPaths.filter((entry) => entry.startsWith(basePrefix))
|
|
||||||
: allPaths;
|
|
||||||
const relativePaths = scopedPaths.map((entry) => basePrefix ? entry.slice(basePrefix.length) : entry);
|
|
||||||
const filteredPaths = parsed.filePath
|
|
||||||
? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!))
|
|
||||||
: relativePaths;
|
|
||||||
const skillPaths = filteredPaths.filter(
|
|
||||||
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
|
|
||||||
);
|
|
||||||
if (skillPaths.length === 0) {
|
|
||||||
throw unprocessable(
|
|
||||||
"No SKILL.md files were found in the provided GitHub source.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const skills: ImportedSkill[] = [];
|
return await readGitHubUrlSkillImports(companyId, sourceUrl, requestedSkillSlug);
|
||||||
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 parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
|
||||||
const skillDir = path.posix.dirname(relativeSkillPath);
|
|
||||||
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
|
||||||
const skillKey = readCanonicalSkillKey(
|
|
||||||
parsedMarkdown.frontmatter,
|
|
||||||
isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null,
|
|
||||||
);
|
|
||||||
if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const metadata = {
|
|
||||||
...(skillKey ? { skillKey } : {}),
|
|
||||||
sourceKind: "github",
|
|
||||||
...(parsed.hostname !== "github.com" ? { hostname: parsed.hostname } : {}),
|
|
||||||
owner: parsed.owner,
|
|
||||||
repo: parsed.repo,
|
|
||||||
ref,
|
|
||||||
trackingRef,
|
|
||||||
repoSkillDir: normalizeGitHubSkillDirectory(
|
|
||||||
basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
|
||||||
slug,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const inventory = filteredPaths
|
|
||||||
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
|
||||||
.map((entry) => ({
|
|
||||||
path: entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1),
|
|
||||||
kind: classifyInventoryKind(entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1)),
|
|
||||||
}))
|
|
||||||
.sort((left, right) => left.path.localeCompare(right.path));
|
|
||||||
skills.push({
|
|
||||||
key: deriveCanonicalSkillKey(companyId, {
|
|
||||||
slug,
|
|
||||||
sourceType: "github",
|
|
||||||
sourceLocator: sourceUrl,
|
|
||||||
metadata,
|
|
||||||
}),
|
|
||||||
slug,
|
|
||||||
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
|
||||||
description: asString(parsedMarkdown.frontmatter.description),
|
|
||||||
markdown,
|
|
||||||
sourceType: "github",
|
|
||||||
sourceLocator: sourceUrl,
|
|
||||||
sourceRef: ref,
|
|
||||||
trustLevel: deriveTrustLevel(inventory),
|
|
||||||
compatibility: "compatible",
|
|
||||||
fileInventory: inventory,
|
|
||||||
metadata,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (skills.length === 0) {
|
|
||||||
throw unprocessable(
|
|
||||||
requestedSkillSlug
|
|
||||||
? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.`
|
|
||||||
: "No SKILL.md files were found in the provided GitHub source.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { skills, warnings };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
@@ -1259,6 +1195,201 @@ async function readUrlSkillImports(
|
|||||||
throw unprocessable("Unsupported skill source. Use a local path or URL.");
|
throw unprocessable("Unsupported skill source. Use a local path or URL.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readGitHubUrlSkillImports(
|
||||||
|
companyId: string,
|
||||||
|
sourceUrl: string,
|
||||||
|
requestedSkillSlug: string | null = null,
|
||||||
|
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const parsed = parseGitHubSourceUrl(sourceUrl);
|
||||||
|
const apiBase = gitHubApiBase(parsed.hostname);
|
||||||
|
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
|
||||||
|
const ref = pinnedRef;
|
||||||
|
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
||||||
|
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
||||||
|
).catch(() => {
|
||||||
|
throw unprocessable(`Failed to read GitHub tree for ${sourceUrl}`);
|
||||||
|
});
|
||||||
|
const allPaths = (tree.tree ?? [])
|
||||||
|
.filter((entry) => entry.type === "blob")
|
||||||
|
.map((entry) => entry.path)
|
||||||
|
.filter((entry): entry is string => typeof entry === "string");
|
||||||
|
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
|
||||||
|
const scopedPaths = basePrefix
|
||||||
|
? allPaths.filter((entry) => entry.startsWith(basePrefix))
|
||||||
|
: allPaths;
|
||||||
|
const relativePaths = scopedPaths.map((entry) => basePrefix ? entry.slice(basePrefix.length) : entry);
|
||||||
|
const filteredPaths = parsed.filePath
|
||||||
|
? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!))
|
||||||
|
: relativePaths;
|
||||||
|
const skillPaths = filteredPaths.filter(
|
||||||
|
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
|
||||||
|
);
|
||||||
|
if (skillPaths.length === 0) {
|
||||||
|
throw unprocessable(
|
||||||
|
"No SKILL.md files were found in the provided GitHub source.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
|
const skillDir = path.posix.dirname(relativeSkillPath);
|
||||||
|
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
||||||
|
const skillKey = readCanonicalSkillKey(
|
||||||
|
parsedMarkdown.frontmatter,
|
||||||
|
isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null,
|
||||||
|
);
|
||||||
|
if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const metadata = {
|
||||||
|
...(skillKey ? { skillKey } : {}),
|
||||||
|
sourceKind: "github",
|
||||||
|
...(parsed.hostname !== "github.com" ? { hostname: parsed.hostname } : {}),
|
||||||
|
owner: parsed.owner,
|
||||||
|
repo: parsed.repo,
|
||||||
|
ref,
|
||||||
|
trackingRef,
|
||||||
|
repoSkillDir: normalizeGitHubSkillDirectory(
|
||||||
|
basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
||||||
|
slug,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const inventory = filteredPaths
|
||||||
|
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
||||||
|
.map((entry) => ({
|
||||||
|
path: entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1),
|
||||||
|
kind: classifyInventoryKind(entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1)),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
skills.push({
|
||||||
|
key: deriveCanonicalSkillKey(companyId, {
|
||||||
|
slug,
|
||||||
|
sourceType: "github",
|
||||||
|
sourceLocator: sourceUrl,
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
|
slug,
|
||||||
|
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
||||||
|
description: asString(parsedMarkdown.frontmatter.description),
|
||||||
|
markdown,
|
||||||
|
sourceType: "github",
|
||||||
|
sourceLocator: sourceUrl,
|
||||||
|
sourceRef: ref,
|
||||||
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
|
compatibility: "compatible",
|
||||||
|
fileInventory: inventory,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (skills.length === 0) {
|
||||||
|
throw unprocessable(
|
||||||
|
requestedSkillSlug
|
||||||
|
? `Skill ${requestedSkillSlug} was not found in the provided GitHub source.`
|
||||||
|
: "No SKILL.md files were found in the provided GitHub source.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { skills, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readGiteaUrlSkillImports(
|
||||||
|
companyId: string,
|
||||||
|
sourceUrl: string,
|
||||||
|
requestedSkillSlug: string | null = null,
|
||||||
|
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const parsed = parseGiteaSourceUrl(sourceUrl);
|
||||||
|
const apiBase = giteaApiBase(parsed.hostname);
|
||||||
|
const { pinnedRef, trackingRef } = await resolveGiteaPinnedRef(parsed);
|
||||||
|
const ref = pinnedRef;
|
||||||
|
let allPaths: string[];
|
||||||
|
try {
|
||||||
|
allPaths = await fetchGiteaTreeBlobPaths(apiBase, parsed.owner, parsed.repo, ref);
|
||||||
|
} catch {
|
||||||
|
throw unprocessable(`Failed to read Gitea tree for ${sourceUrl}`);
|
||||||
|
}
|
||||||
|
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
|
||||||
|
const scopedPaths = basePrefix
|
||||||
|
? allPaths.filter((entry) => entry.startsWith(basePrefix))
|
||||||
|
: allPaths;
|
||||||
|
const relativePaths = scopedPaths.map((entry) => basePrefix ? entry.slice(basePrefix.length) : entry);
|
||||||
|
const filteredPaths = parsed.filePath
|
||||||
|
? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!))
|
||||||
|
: relativePaths;
|
||||||
|
const skillPaths = filteredPaths.filter(
|
||||||
|
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
|
||||||
|
);
|
||||||
|
if (skillPaths.length === 0) {
|
||||||
|
throw unprocessable(
|
||||||
|
"No SKILL.md files were found in the provided Gitea source.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const skills: ImportedSkill[] = [];
|
||||||
|
for (const relativeSkillPath of skillPaths) {
|
||||||
|
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
|
||||||
|
const markdown = await fetchGiteaText(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath);
|
||||||
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
|
const skillDir = path.posix.dirname(relativeSkillPath);
|
||||||
|
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
||||||
|
const skillKey = readCanonicalSkillKey(
|
||||||
|
parsedMarkdown.frontmatter,
|
||||||
|
isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null,
|
||||||
|
);
|
||||||
|
if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const metadata = {
|
||||||
|
...(skillKey ? { skillKey } : {}),
|
||||||
|
sourceKind: "gitea",
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
owner: parsed.owner,
|
||||||
|
repo: parsed.repo,
|
||||||
|
ref,
|
||||||
|
trackingRef,
|
||||||
|
repoSkillDir: normalizeGitHubSkillDirectory(
|
||||||
|
basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
||||||
|
slug,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const inventory = filteredPaths
|
||||||
|
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
||||||
|
.map((entry) => ({
|
||||||
|
path: entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1),
|
||||||
|
kind: classifyInventoryKind(entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1)),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
skills.push({
|
||||||
|
key: deriveCanonicalSkillKey(companyId, {
|
||||||
|
slug,
|
||||||
|
sourceType: "gitea",
|
||||||
|
sourceLocator: sourceUrl,
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
|
slug,
|
||||||
|
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
||||||
|
description: asString(parsedMarkdown.frontmatter.description),
|
||||||
|
markdown,
|
||||||
|
sourceType: "gitea",
|
||||||
|
sourceLocator: sourceUrl,
|
||||||
|
sourceRef: ref,
|
||||||
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
|
compatibility: "compatible",
|
||||||
|
fileInventory: inventory,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (skills.length === 0) {
|
||||||
|
throw unprocessable(
|
||||||
|
requestedSkillSlug
|
||||||
|
? `Skill ${requestedSkillSlug} was not found in the provided Gitea source.`
|
||||||
|
: "No SKILL.md files were found in the provided Gitea source.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { skills, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
function toCompanySkill(row: CompanySkillRow): CompanySkill {
|
function toCompanySkill(row: CompanySkillRow): CompanySkill {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
@@ -1789,6 +1920,18 @@ function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skill.sourceType === "gitea") {
|
||||||
|
const owner = asString(metadata.owner) ?? null;
|
||||||
|
const repo = asString(metadata.repo) ?? null;
|
||||||
|
return {
|
||||||
|
editable: false,
|
||||||
|
editableReason: "Remote Gitea skills are read-only. Fork or import locally to edit them.",
|
||||||
|
sourceLabel: owner && repo ? `${owner}/${repo}` : skill.sourceLocator,
|
||||||
|
sourceBadge: "gitea",
|
||||||
|
sourcePath: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (skill.sourceType === "url") {
|
if (skill.sourceType === "url") {
|
||||||
return {
|
return {
|
||||||
editable: false,
|
editable: false,
|
||||||
@@ -2211,10 +2354,10 @@ export function companySkillService(db: Db) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") {
|
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh" && skill.sourceType !== "gitea") {
|
||||||
return {
|
return {
|
||||||
supported: false,
|
supported: false,
|
||||||
reason: "Only GitHub-managed skills support update checks.",
|
reason: "Only GitHub-, Gitea-, or skills.sh-managed skills support update checks.",
|
||||||
trackingRef: null,
|
trackingRef: null,
|
||||||
currentRef: skill.sourceRef ?? null,
|
currentRef: skill.sourceRef ?? null,
|
||||||
latestRef: null,
|
latestRef: null,
|
||||||
@@ -2229,7 +2372,7 @@ export function companySkillService(db: Db) {
|
|||||||
if (!owner || !repo || !trackingRef) {
|
if (!owner || !repo || !trackingRef) {
|
||||||
return {
|
return {
|
||||||
supported: false,
|
supported: false,
|
||||||
reason: "This GitHub skill does not have enough metadata to track updates.",
|
reason: "This skill does not have enough metadata to track updates.",
|
||||||
trackingRef: trackingRef ?? null,
|
trackingRef: trackingRef ?? null,
|
||||||
currentRef: skill.sourceRef ?? null,
|
currentRef: skill.sourceRef ?? null,
|
||||||
latestRef: null,
|
latestRef: null,
|
||||||
@@ -2238,9 +2381,30 @@ export function companySkillService(db: Db) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = asString(metadata.hostname) || "github.com";
|
const hostname = asString(metadata.hostname) || (skill.sourceType === "gitea" ? "" : "github.com");
|
||||||
const apiBase = gitHubApiBase(hostname);
|
let latestRef: string;
|
||||||
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
|
if (skill.sourceType === "gitea") {
|
||||||
|
if (!hostname) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
reason: "This Gitea skill does not have a hostname in its metadata.",
|
||||||
|
trackingRef,
|
||||||
|
currentRef: skill.sourceRef ?? null,
|
||||||
|
latestRef: null,
|
||||||
|
hasUpdate: false,
|
||||||
|
...statusMeta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const branch = await fetchGiteaBranch(giteaApiBase(hostname), owner, repo, trackingRef);
|
||||||
|
const branchSha = asString(branch.commit?.id);
|
||||||
|
if (!branchSha) {
|
||||||
|
throw unprocessable(`Failed to resolve Gitea branch ${trackingRef} for ${owner}/${repo}`);
|
||||||
|
}
|
||||||
|
latestRef = branchSha;
|
||||||
|
} else {
|
||||||
|
const apiBase = gitHubApiBase(hostname);
|
||||||
|
latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
supported: true,
|
supported: true,
|
||||||
reason: null,
|
reason: null,
|
||||||
@@ -2287,6 +2451,18 @@ export function companySkillService(db: Db) {
|
|||||||
}
|
}
|
||||||
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
|
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));
|
||||||
|
} else if (skill.sourceType === "gitea") {
|
||||||
|
const metadata = getSkillMeta(skill);
|
||||||
|
const owner = asString(metadata.owner);
|
||||||
|
const repo = asString(metadata.repo);
|
||||||
|
const hostname = asString(metadata.hostname);
|
||||||
|
const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main";
|
||||||
|
const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug);
|
||||||
|
if (!owner || !repo || !hostname) {
|
||||||
|
throw unprocessable("Skill source metadata is incomplete.");
|
||||||
|
}
|
||||||
|
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
|
||||||
|
content = await fetchGiteaText(hostname, owner, repo, ref, repoPath);
|
||||||
} else if (skill.sourceType === "url") {
|
} else if (skill.sourceType === "url") {
|
||||||
if (normalizedPath !== "SKILL.md") {
|
if (normalizedPath !== "SKILL.md") {
|
||||||
throw notFound("This skill source only exposes SKILL.md");
|
throw notFound("This skill source only exposes SKILL.md");
|
||||||
|
|||||||
@@ -1266,7 +1266,7 @@ async function buildAgentContext(
|
|||||||
sourceType: skill.sourceType,
|
sourceType: skill.sourceType,
|
||||||
sourceLocator: skill.sourceLocator == null
|
sourceLocator: skill.sourceLocator == null
|
||||||
? null
|
? null
|
||||||
: skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url"
|
: skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "gitea" || skill.sourceType === "url"
|
||||||
? skill.sourceLocator
|
? skill.sourceLocator
|
||||||
: sanitizeFeedbackText(
|
: sanitizeFeedbackText(
|
||||||
skill.sourceLocator,
|
skill.sourceLocator,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { unprocessable } from "../errors.js";
|
||||||
|
|
||||||
|
const PROBE_CACHE_MAX_ENTRIES = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject hostnames that resolve to loopback, link-local, or RFC1918 ranges
|
||||||
|
* supplied as literal IPs. Hostnames that resolve via DNS to private IPs are
|
||||||
|
* not blocked here — this is a cheap surface-level guard against an operator
|
||||||
|
* pasting `http://192.168.1.10/...` into a skill-source field, not a full
|
||||||
|
* SSRF defence.
|
||||||
|
*/
|
||||||
|
export function isPrivateOrLoopbackHost(hostname: string): boolean {
|
||||||
|
const host = hostname.toLowerCase().trim();
|
||||||
|
if (host === "localhost" || host === "ip6-localhost" || host === "ip6-loopback") return true;
|
||||||
|
if (host.endsWith(".localhost")) return true;
|
||||||
|
if (host === "::1" || host === "[::1]") return true;
|
||||||
|
if (host.startsWith("fe80:") || host.startsWith("[fe80:")) return true;
|
||||||
|
if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("[fc") || host.startsWith("[fd")) return true;
|
||||||
|
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||||
|
if (ipv4) {
|
||||||
|
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
||||||
|
if (a === 10) return true;
|
||||||
|
if (a === 127) return true;
|
||||||
|
if (a === 0) return true;
|
||||||
|
if (a === 169 && b === 254) return true;
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||||
|
if (a === 192 && b === 168) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPublicHost(hostname: string): void {
|
||||||
|
if (isPrivateOrLoopbackHost(hostname)) {
|
||||||
|
throw unprocessable(
|
||||||
|
`Refusing to contact ${hostname}: private, loopback, and link-local hosts are not allowed as skill sources.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-lifetime cache of Gitea/Forgejo probe results.
|
||||||
|
* Keyed by lowercased hostname. Positive and negative results are both cached
|
||||||
|
* to avoid re-probing the same host on every import. FIFO-evicted at
|
||||||
|
* PROBE_CACHE_MAX_ENTRIES to bound memory.
|
||||||
|
*/
|
||||||
|
export const giteaHostProbeCache = new Map<string, boolean>();
|
||||||
|
|
||||||
|
function evictProbeCacheIfFull() {
|
||||||
|
if (giteaHostProbeCache.size > PROBE_CACHE_MAX_ENTRIES) {
|
||||||
|
const oldestKey = giteaHostProbeCache.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) giteaHostProbeCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGiteaHostProbe(hostname: string, isGitea: boolean) {
|
||||||
|
giteaHostProbeCache.set(hostname.toLowerCase(), isGitea);
|
||||||
|
evictProbeCacheIfFull();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGiteaHostProbe(hostname: string): boolean | undefined {
|
||||||
|
return giteaHostProbeCache.get(hostname.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea/Forgejo API base. There is no dotcom short-circuit — every host uses
|
||||||
|
* the same /api/v1 path, including the public gitea.com instance.
|
||||||
|
*/
|
||||||
|
export function giteaApiBase(hostname: string) {
|
||||||
|
return `https://${hostname}/api/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical raw-content URL for Gitea/Forgejo ≥ 1.18.
|
||||||
|
* Modern format: `https://{host}/{owner}/{repo}/raw/branch/{ref}/{path}`.
|
||||||
|
*/
|
||||||
|
export function resolveRawGiteaUrl(
|
||||||
|
hostname: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
ref: string,
|
||||||
|
filePath: string,
|
||||||
|
) {
|
||||||
|
const p = filePath.replace(/^\/+/, "");
|
||||||
|
return `https://${hostname}/${owner}/${repo}/raw/branch/${ref}/${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy raw-content URL for Gitea < 1.18 and some Forgejo setups.
|
||||||
|
* Format: `https://{host}/{owner}/{repo}/raw/{ref}/{path}`.
|
||||||
|
*/
|
||||||
|
export function resolveRawGiteaUrlLegacy(
|
||||||
|
hostname: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
ref: string,
|
||||||
|
filePath: string,
|
||||||
|
) {
|
||||||
|
const p = filePath.replace(/^\/+/, "");
|
||||||
|
return `https://${hostname}/${owner}/${repo}/raw/${ref}/${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await fetch(url, init);
|
||||||
|
} catch {
|
||||||
|
throw unprocessable(
|
||||||
|
`Could not connect to ${new URL(url).hostname} — ensure the URL points to a Gitea/Forgejo instance`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { unprocessable } from "../errors.js";
|
||||||
|
import {
|
||||||
|
assertPublicHost,
|
||||||
|
giteaApiBase,
|
||||||
|
giteaFetch,
|
||||||
|
getGiteaHostProbe,
|
||||||
|
giteaHostProbeCache,
|
||||||
|
isPrivateOrLoopbackHost,
|
||||||
|
resolveRawGiteaUrl,
|
||||||
|
resolveRawGiteaUrlLegacy,
|
||||||
|
setGiteaHostProbe,
|
||||||
|
} from "./gitea-fetch.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
assertPublicHost,
|
||||||
|
giteaApiBase,
|
||||||
|
giteaHostProbeCache,
|
||||||
|
isPrivateOrLoopbackHost,
|
||||||
|
resolveRawGiteaUrl,
|
||||||
|
resolveRawGiteaUrlLegacy,
|
||||||
|
setGiteaHostProbe,
|
||||||
|
getGiteaHostProbe,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 3000;
|
||||||
|
const GITEA_TREE_PAGE_LIMIT = 1000;
|
||||||
|
|
||||||
|
export type GiteaSourceUrl = {
|
||||||
|
hostname: string;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
ref: string;
|
||||||
|
basePath: string;
|
||||||
|
filePath: string | null;
|
||||||
|
explicitRef: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaBranchResponse = {
|
||||||
|
name?: string;
|
||||||
|
commit?: { id?: string; url?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaRepoResponse = {
|
||||||
|
default_branch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaTreeEntry = {
|
||||||
|
path?: string;
|
||||||
|
type?: string;
|
||||||
|
mode?: string;
|
||||||
|
sha?: string;
|
||||||
|
size?: number;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiteaTreeResponse = {
|
||||||
|
sha?: string;
|
||||||
|
tree?: GiteaTreeEntry[];
|
||||||
|
truncated?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asString(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Gitea/Forgejo HTTPS repo URL into its components.
|
||||||
|
* Mirrors parseGitHubSourceUrl (server/src/services/company-skills.ts:634-660).
|
||||||
|
* Accepts:
|
||||||
|
* https://{host}/{owner}/{repo}
|
||||||
|
* https://{host}/{owner}/{repo}.git
|
||||||
|
* https://{host}/{owner}/{repo}/tree/{ref}/{basePath...}
|
||||||
|
* https://{host}/{owner}/{repo}/blob/{ref}/{filePath}
|
||||||
|
*/
|
||||||
|
export function parseGiteaSourceUrl(rawUrl: string): GiteaSourceUrl {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw unprocessable("Invalid Gitea URL");
|
||||||
|
}
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
throw unprocessable("Gitea source URL must use HTTPS");
|
||||||
|
}
|
||||||
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw unprocessable("Invalid Gitea URL");
|
||||||
|
}
|
||||||
|
const owner = parts[0]!;
|
||||||
|
const repo = parts[1]!.replace(/\.git$/i, "");
|
||||||
|
if (!owner || !repo) {
|
||||||
|
throw unprocessable("Invalid Gitea URL: owner and repo are required");
|
||||||
|
}
|
||||||
|
let ref = "main";
|
||||||
|
let basePath = "";
|
||||||
|
let filePath: string | null = null;
|
||||||
|
let explicitRef = false;
|
||||||
|
if (parts[2] === "tree") {
|
||||||
|
ref = parts[3] ?? "main";
|
||||||
|
basePath = parts.slice(4).join("/");
|
||||||
|
explicitRef = true;
|
||||||
|
} else if (parts[2] === "blob") {
|
||||||
|
ref = parts[3] ?? "main";
|
||||||
|
filePath = parts.slice(4).join("/");
|
||||||
|
basePath = filePath ? path.posix.dirname(filePath) : "";
|
||||||
|
explicitRef = true;
|
||||||
|
}
|
||||||
|
assertPublicHost(url.hostname);
|
||||||
|
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe a hostname to determine if it hosts a Gitea/Forgejo instance.
|
||||||
|
* GETs `https://{host}/api/v1/version` with a short timeout. Cached for
|
||||||
|
* the process lifetime in giteaHostProbeCache.
|
||||||
|
*
|
||||||
|
* Returns false without contacting the host for loopback / link-local /
|
||||||
|
* RFC1918 literal-IP hosts, to avoid being used as an SSRF probe.
|
||||||
|
*/
|
||||||
|
export async function probeGiteaHost(hostname: string): Promise<boolean> {
|
||||||
|
const key = hostname.toLowerCase();
|
||||||
|
const cached = getGiteaHostProbe(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
if (isPrivateOrLoopbackHost(key)) {
|
||||||
|
setGiteaHostProbe(key, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||||
|
let result = false;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://${key}/api/v1/version`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json().catch(() => null)) as unknown;
|
||||||
|
if (isPlainRecord(data) && typeof data.version === "string") {
|
||||||
|
result = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// network error, abort, parse error — all treated as "not gitea"
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGiteaHostProbe(key, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveGiteaDefaultBranch(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
apiBase: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetchGiteaJson<GiteaRepoResponse>(`${apiBase}/repos/${owner}/${repo}`);
|
||||||
|
return asString(response.default_branch) ?? "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a parsed Gitea URL into a pinned commit SHA and a tracking ref.
|
||||||
|
* Mirrors resolveGitHubPinnedRef (server/src/services/company-skills.ts:662-676).
|
||||||
|
*/
|
||||||
|
export async function resolveGiteaPinnedRef(parsed: GiteaSourceUrl): Promise<{
|
||||||
|
pinnedRef: string;
|
||||||
|
trackingRef: string | null;
|
||||||
|
}> {
|
||||||
|
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
|
||||||
|
return {
|
||||||
|
pinnedRef: parsed.ref,
|
||||||
|
trackingRef: parsed.explicitRef ? parsed.ref : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = giteaApiBase(parsed.hostname);
|
||||||
|
const trackingRef = parsed.explicitRef
|
||||||
|
? parsed.ref
|
||||||
|
: await resolveGiteaDefaultBranch(parsed.owner, parsed.repo, apiBase);
|
||||||
|
// Gitea's /repos/{o}/{r}/commits/{ref} endpoint only resolves SHAs — a branch
|
||||||
|
// name returns 404. The branches endpoint accepts both branch names and tags.
|
||||||
|
const branch = await fetchGiteaBranch(apiBase, parsed.owner, parsed.repo, trackingRef);
|
||||||
|
const pinnedRef = asString(branch.commit?.id);
|
||||||
|
if (!pinnedRef) {
|
||||||
|
throw unprocessable(`Failed to resolve Gitea ref ${trackingRef}`);
|
||||||
|
}
|
||||||
|
return { pinnedRef, trackingRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the full list of blob paths in a repo tree at a given ref.
|
||||||
|
* Paginates with `?page=N&limit=1000` when the response is truncated.
|
||||||
|
*/
|
||||||
|
export async function fetchGiteaTreeBlobPaths(
|
||||||
|
apiBase: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
ref: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const all: string[] = [];
|
||||||
|
let page = 1;
|
||||||
|
// hard cap so a misconfigured host can't make us loop forever
|
||||||
|
const MAX_PAGES = 50;
|
||||||
|
let stillTruncated = false;
|
||||||
|
for (let i = 0; i < MAX_PAGES; i += 1) {
|
||||||
|
const url =
|
||||||
|
page === 1
|
||||||
|
? `${apiBase}/repos/${owner}/${repo}/git/trees/${ref}?recursive=true&limit=${GITEA_TREE_PAGE_LIMIT}`
|
||||||
|
: `${apiBase}/repos/${owner}/${repo}/git/trees/${ref}?recursive=true&limit=${GITEA_TREE_PAGE_LIMIT}&page=${page}`;
|
||||||
|
const data = await fetchGiteaJson<GiteaTreeResponse>(url);
|
||||||
|
const entries = Array.isArray(data.tree) ? data.tree : [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.type === "blob" && typeof entry.path === "string") {
|
||||||
|
all.push(entry.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stillTruncated = Boolean(data.truncated);
|
||||||
|
if (!stillTruncated) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
if (stillTruncated) {
|
||||||
|
// Tree still truncated at the page cap — refuse rather than silently
|
||||||
|
// import a partial skill listing, which would hide SKILL.md files.
|
||||||
|
throw unprocessable(
|
||||||
|
`Gitea repo tree for ${owner}/${repo}@${ref} exceeds ${MAX_PAGES * GITEA_TREE_PAGE_LIMIT} entries; refusing to import a partial listing.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a raw file from a Gitea/Forgejo repo. Tries the modern
|
||||||
|
* /raw/branch/{ref}/{path} URL first, falling back to legacy
|
||||||
|
* /raw/{ref}/{path} on 404.
|
||||||
|
*/
|
||||||
|
export async function fetchGiteaText(
|
||||||
|
hostname: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
ref: string,
|
||||||
|
filePath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const canonical = resolveRawGiteaUrl(hostname, owner, repo, ref, filePath);
|
||||||
|
const canonicalResponse = await giteaFetch(canonical, {
|
||||||
|
headers: { accept: "text/plain" },
|
||||||
|
});
|
||||||
|
if (canonicalResponse.ok) {
|
||||||
|
return canonicalResponse.text();
|
||||||
|
}
|
||||||
|
if (canonicalResponse.status !== 404) {
|
||||||
|
throw unprocessable(
|
||||||
|
`Failed to fetch ${canonical}: ${canonicalResponse.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const legacy = resolveRawGiteaUrlLegacy(hostname, owner, repo, ref, filePath);
|
||||||
|
const legacyResponse = await giteaFetch(legacy, {
|
||||||
|
headers: { accept: "text/plain" },
|
||||||
|
});
|
||||||
|
if (!legacyResponse.ok) {
|
||||||
|
throw unprocessable(
|
||||||
|
`Failed to fetch ${legacy}: ${legacyResponse.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return legacyResponse.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a branch record by name. Used for update checks to resolve
|
||||||
|
* the latest commit SHA on the tracking branch.
|
||||||
|
*/
|
||||||
|
export async function fetchGiteaBranch(
|
||||||
|
apiBase: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
branch: string,
|
||||||
|
): Promise<GiteaBranchResponse> {
|
||||||
|
return fetchGiteaJson<GiteaBranchResponse>(
|
||||||
|
`${apiBase}/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGiteaJson<T>(url: string): Promise<T> {
|
||||||
|
const response = await giteaFetch(url, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
@@ -192,6 +193,8 @@ function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string |
|
|||||||
return isSkillsShManaged
|
return isSkillsShManaged
|
||||||
? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" }
|
? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" }
|
||||||
: { icon: Github, label: sourceLabel ?? "GitHub", managedLabel: "GitHub managed" };
|
: { icon: Github, label: sourceLabel ?? "GitHub", managedLabel: "GitHub managed" };
|
||||||
|
case "gitea":
|
||||||
|
return { icon: Server, label: sourceLabel ?? "Gitea", managedLabel: "Gitea managed" };
|
||||||
case "url":
|
case "url":
|
||||||
return { icon: Link2, label: sourceLabel ?? "URL", managedLabel: "URL managed" };
|
return { icon: Link2, label: sourceLabel ?? "URL", managedLabel: "URL managed" };
|
||||||
case "local":
|
case "local":
|
||||||
@@ -329,7 +332,7 @@ function classifySource(skill: {
|
|||||||
if (kind === "optional") return "optional";
|
if (kind === "optional") return "optional";
|
||||||
return "company";
|
return "company";
|
||||||
}
|
}
|
||||||
if (skill.sourceBadge === "github" || skill.sourceBadge === "skills_sh" || skill.sourceBadge === "url" || skill.sourceBadge === "local") {
|
if (skill.sourceBadge === "github" || skill.sourceBadge === "skills_sh" || skill.sourceBadge === "gitea" || skill.sourceBadge === "url" || skill.sourceBadge === "local") {
|
||||||
return "external";
|
return "external";
|
||||||
}
|
}
|
||||||
return "company";
|
return "company";
|
||||||
@@ -1512,7 +1515,7 @@ function SkillPane({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{detail.sourceType === "github" && (
|
{(detail.sourceType === "github" || detail.sourceType === "gitea") && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
|
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
|
||||||
<span className="font-mono text-xs">{currentPin ?? "untracked"}</span>
|
<span className="font-mono text-xs">{currentPin ?? "untracked"}</span>
|
||||||
@@ -1802,7 +1805,7 @@ export function CompanySkills() {
|
|||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
selectedCompanyId
|
selectedCompanyId
|
||||||
&& selectedSkillId
|
&& selectedSkillId
|
||||||
&& (detailQuery.data?.sourceType === "github" || displayedDetail?.sourceType === "github"),
|
&& (detailQuery.data?.sourceType === "github" || detailQuery.data?.sourceType === "gitea" || displayedDetail?.sourceType === "github" || displayedDetail?.sourceType === "gitea"),
|
||||||
),
|
),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
@@ -2297,7 +2300,7 @@ export function CompanySkills() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add a skill source</DialogTitle>
|
<DialogTitle>Add a skill source</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Paste a local path, GitHub URL, or `skills.sh` command into the field first.
|
Paste a local path, GitHub or Gitea/Forgejo URL, or `skills.sh` command into the field first.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
@@ -2437,7 +2440,7 @@ export function CompanySkills() {
|
|||||||
<input
|
<input
|
||||||
value={source}
|
value={source}
|
||||||
onChange={(event) => setSource(event.target.value)}
|
onChange={(event) => setSource(event.target.value)}
|
||||||
placeholder="Paste path, GitHub URL, or skills.sh command"
|
placeholder="Paste path, GitHub or Gitea/Forgejo URL, or skills.sh command"
|
||||||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user