mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: add owner rescan security surfaces
This commit is contained in:
@@ -78,6 +78,8 @@ describe("devSeed rescan UX fixtures", () => {
|
||||
flaggedSkillMd: "# Flagged skill",
|
||||
flaggedPluginStorageId: "storage:plugin",
|
||||
flaggedPluginReadme: "# Flagged plugin",
|
||||
scannedPluginStorageId: "storage:scanned-plugin",
|
||||
scannedPluginReadme: "# Scanned plugin",
|
||||
};
|
||||
|
||||
await seedRescanUxFixturesHandler({ db } as never, args as never);
|
||||
@@ -96,14 +98,36 @@ describe("devSeed rescan UX fixtures", () => {
|
||||
moderationVerdict: "malicious",
|
||||
}),
|
||||
);
|
||||
expect(tables.packages).toHaveLength(1);
|
||||
expect(tables.packages?.[0]).toEqual(
|
||||
expect(tables.packages).toHaveLength(2);
|
||||
expect(tables.packages?.find((pkg) => pkg.name === "local-flagged-runtime-plugin")).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
scanStatus: "malicious",
|
||||
}),
|
||||
);
|
||||
expect(tables.packages?.find((pkg) => pkg.name === "local-scanned-runtime-plugin")).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
scanStatus: "suspicious",
|
||||
}),
|
||||
);
|
||||
|
||||
const scannedPackage = tables.packages?.find(
|
||||
(pkg) => pkg.name === "local-scanned-runtime-plugin",
|
||||
);
|
||||
const scannedRelease = tables.packageReleases?.find(
|
||||
(release) => release.packageId === scannedPackage?._id,
|
||||
);
|
||||
expect(scannedRelease).toEqual(
|
||||
expect.objectContaining({
|
||||
sha256hash: "seeded-scanned-plugin-hash",
|
||||
vtAnalysis: expect.objectContaining({ status: "clean" }),
|
||||
llmAnalysis: expect.objectContaining({ status: "suspicious" }),
|
||||
staticScan: expect.objectContaining({ status: "suspicious" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const skillRequests =
|
||||
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
|
||||
|
||||
+281
-6
@@ -2,11 +2,13 @@ import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { internalMutation as rawInternalMutation } from "./_generated/server";
|
||||
import { internalAction, internalMutation } from "./functions";
|
||||
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
|
||||
import { normalizePackageName } from "./lib/packageRegistry";
|
||||
import { ensurePersonalPublisherForUser } from "./lib/publishers";
|
||||
import { parseClawdisMetadata, parseFrontmatter } from "./lib/skills";
|
||||
import { generateToken, hashToken } from "./lib/tokens";
|
||||
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
|
||||
|
||||
type SeedSkillSpec = {
|
||||
@@ -32,6 +34,7 @@ type SeedMutationResult = Record<string, unknown>;
|
||||
const LOCAL_SEED_HANDLE = "local";
|
||||
const FLAGGED_SKILL_SLUG = "local-flagged-wallet-sync";
|
||||
const FLAGGED_PLUGIN_NAME = "local-flagged-runtime-plugin";
|
||||
const SCANNED_PLUGIN_NAME = "local-scanned-runtime-plugin";
|
||||
const FLAGGED_SKILL_MD = `---
|
||||
name: local-flagged-wallet-sync
|
||||
description: Local dev fixture for flagged dashboard and rescan UI.
|
||||
@@ -47,6 +50,17 @@ const FLAGGED_PLUGIN_README = `# Local Flagged Runtime Plugin
|
||||
This seeded plugin is intentionally flagged so local development can exercise plugin owner
|
||||
inventory and cap-exhausted rescan UI.
|
||||
`;
|
||||
const SCANNED_PLUGIN_README = `# Local Scanned Runtime Plugin
|
||||
|
||||
This seeded plugin is public and intentionally has completed scan results so local development can
|
||||
preview plugin scanner detail pages without owner-only visibility.
|
||||
`;
|
||||
|
||||
type RoleHelpFixtureUser = {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
role: "admin" | "user";
|
||||
};
|
||||
|
||||
const SEED_SKILLS: SeedSkillSpec[] = [
|
||||
{
|
||||
@@ -368,9 +382,11 @@ async function seedNixSkillsHandler(
|
||||
results.push({ slug: spec.slug, ...result });
|
||||
}
|
||||
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId] = await Promise.all([
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] =
|
||||
await Promise.all([
|
||||
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
|
||||
]);
|
||||
const fixtureResult: SeedMutationResult = await ctx.runMutation(
|
||||
internal.devSeed.seedRescanUxFixturesMutation,
|
||||
@@ -380,6 +396,8 @@ async function seedNixSkillsHandler(
|
||||
flaggedSkillMd: FLAGGED_SKILL_MD,
|
||||
flaggedPluginStorageId,
|
||||
flaggedPluginReadme: FLAGGED_PLUGIN_README,
|
||||
scannedPluginStorageId,
|
||||
scannedPluginReadme: SCANNED_PLUGIN_README,
|
||||
},
|
||||
);
|
||||
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
|
||||
@@ -506,8 +524,8 @@ async function findSeedSkillFixture(ctx: MutationCtx) {
|
||||
.unique();
|
||||
}
|
||||
|
||||
async function deleteSeedPluginFixture(ctx: MutationCtx) {
|
||||
const existing = await findSeedPluginFixture(ctx);
|
||||
async function deleteSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
|
||||
const existing = await findSeedPluginFixtureByName(ctx, name);
|
||||
if (!existing) return;
|
||||
|
||||
const releases = await ctx.db
|
||||
@@ -521,13 +539,29 @@ async function deleteSeedPluginFixture(ctx: MutationCtx) {
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
async function findSeedPluginFixture(ctx: MutationCtx) {
|
||||
async function deleteSeedPluginFixture(ctx: MutationCtx) {
|
||||
await deleteSeedPluginFixtureByName(ctx, FLAGGED_PLUGIN_NAME);
|
||||
}
|
||||
|
||||
async function deleteScannedPluginFixture(ctx: MutationCtx) {
|
||||
await deleteSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
|
||||
}
|
||||
|
||||
async function findSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
|
||||
return await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_name", (q) => q.eq("normalizedName", normalizePackageName(FLAGGED_PLUGIN_NAME)))
|
||||
.withIndex("by_name", (q) => q.eq("normalizedName", normalizePackageName(name)))
|
||||
.unique();
|
||||
}
|
||||
|
||||
async function findSeedPluginFixture(ctx: MutationCtx) {
|
||||
return await findSeedPluginFixtureByName(ctx, FLAGGED_PLUGIN_NAME);
|
||||
}
|
||||
|
||||
async function findScannedPluginFixture(ctx: MutationCtx) {
|
||||
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
|
||||
}
|
||||
|
||||
function staticMaliciousScan(now: number) {
|
||||
return {
|
||||
status: "malicious" as const,
|
||||
@@ -548,6 +582,26 @@ function staticMaliciousScan(now: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function staticSuspiciousScan(now: number) {
|
||||
return {
|
||||
status: "suspicious" as const,
|
||||
reasonCodes: ["suspicious.local_dev_fixture"],
|
||||
findings: [
|
||||
{
|
||||
code: "suspicious.local_dev_fixture",
|
||||
severity: "warn" as const,
|
||||
file: "README.md",
|
||||
line: 3,
|
||||
message: "Local dev fixture exercises scanner evidence UI for a public plugin.",
|
||||
evidence: "runtime plugin requests local tool execution",
|
||||
},
|
||||
],
|
||||
summary: "Local dev fixture completed static analysis with a suspicious finding.",
|
||||
engineVersion: "local-dev-fixture",
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function insertCompletedRescanRequests(
|
||||
ctx: MutationCtx,
|
||||
params:
|
||||
@@ -602,6 +656,8 @@ type SeedRescanUxFixturesArgs = {
|
||||
flaggedSkillMd: string;
|
||||
flaggedPluginStorageId: Id<"_storage">;
|
||||
flaggedPluginReadme: string;
|
||||
scannedPluginStorageId: Id<"_storage">;
|
||||
scannedPluginReadme: string;
|
||||
};
|
||||
|
||||
export async function seedRescanUxFixturesHandler(
|
||||
@@ -610,7 +666,8 @@ export async function seedRescanUxFixturesHandler(
|
||||
) {
|
||||
const existingSkill = await findSeedSkillFixture(ctx);
|
||||
const existingPlugin = await findSeedPluginFixture(ctx);
|
||||
if (existingSkill && existingPlugin && !args.reset) {
|
||||
const existingScannedPlugin = await findScannedPluginFixture(ctx);
|
||||
if (existingSkill && existingPlugin && existingScannedPlugin && !args.reset) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
@@ -620,15 +677,19 @@ export async function seedRescanUxFixturesHandler(
|
||||
flaggedSkillVersionId: existingSkill.latestVersionId,
|
||||
flaggedPluginId: existingPlugin._id,
|
||||
flaggedPluginReleaseId: existingPlugin.latestReleaseId,
|
||||
scannedPluginId: existingScannedPlugin._id,
|
||||
scannedPluginReleaseId: existingScannedPlugin.latestReleaseId,
|
||||
};
|
||||
}
|
||||
|
||||
await deleteSeedSkillFixture(ctx);
|
||||
await deleteSeedPluginFixture(ctx);
|
||||
await deleteScannedPluginFixture(ctx);
|
||||
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
const staticScan = staticMaliciousScan(now);
|
||||
const scannedStaticScan = staticSuspiciousScan(now);
|
||||
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: FLAGGED_SKILL_SLUG,
|
||||
@@ -859,6 +920,136 @@ export async function seedRescanUxFixturesHandler(
|
||||
count: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
|
||||
now,
|
||||
});
|
||||
|
||||
const scannedPackageId = await ctx.db.insert("packages", {
|
||||
name: SCANNED_PLUGIN_NAME,
|
||||
normalizedName: normalizePackageName(SCANNED_PLUGIN_NAME),
|
||||
displayName: "Local Scanned Runtime Plugin",
|
||||
summary: "Seeded public plugin with completed security scans for scanner page previews.",
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
runtimeId: "local.scanned.runtime",
|
||||
sourceRepo: "openclaw/local-dev-fixture",
|
||||
latestReleaseId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: ["dev-tools", "security"],
|
||||
executesCode: true,
|
||||
compatibility: { pluginApiRange: ">=0.1.0" },
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
runtimeId: "local.scanned.runtime",
|
||||
pluginKind: "runtime",
|
||||
capabilityTags: ["dev-tools", "security"],
|
||||
},
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
summary: "Local dev fixture completed security scans with reviewable findings.",
|
||||
sourceRepo: "openclaw/local-dev-fixture",
|
||||
scanStatus: "suspicious",
|
||||
},
|
||||
scanStatus: "suspicious",
|
||||
stats: { downloads: 7, installs: 1, stars: 1, versions: 0 },
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const scannedPackageReleaseId = await ctx.db.insert("packageReleases", {
|
||||
packageId: scannedPackageId,
|
||||
version: "0.1.0",
|
||||
changelog: "Seeded public scanned release for plugin scanner page previews.",
|
||||
summary: "Seeded scanned plugin release.",
|
||||
distTags: ["latest"],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: args.scannedPluginReadme.length,
|
||||
storageId: args.scannedPluginStorageId,
|
||||
sha256: "seeded-scanned-plugin",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
integritySha256: "seeded-scanned-plugin-integrity",
|
||||
extractedPackageJson: {
|
||||
name: SCANNED_PLUGIN_NAME,
|
||||
version: "0.1.0",
|
||||
},
|
||||
compatibility: { pluginApiRange: ">=0.1.0" },
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
runtimeId: "local.scanned.runtime",
|
||||
pluginKind: "runtime",
|
||||
capabilityTags: ["dev-tools", "security"],
|
||||
},
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
summary: "Local dev fixture completed security scans with reviewable findings.",
|
||||
sourceRepo: "openclaw/local-dev-fixture",
|
||||
scanStatus: "suspicious",
|
||||
},
|
||||
sha256hash: "seeded-scanned-plugin-hash",
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
analysis: "Local dev fixture scanned clean by VirusTotal.",
|
||||
source: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "suspicious",
|
||||
verdict: "suspicious",
|
||||
confidence: "medium",
|
||||
summary: "Local dev fixture flagged for review because it executes local tools.",
|
||||
dimensions: [
|
||||
{
|
||||
name: "execution",
|
||||
label: "Local execution",
|
||||
rating: "concern",
|
||||
detail: "Runtime plugin executes local tooling and should be reviewed before install.",
|
||||
},
|
||||
],
|
||||
guidance: "Review the runtime command surface before trusting this plugin.",
|
||||
findings: "The fixture is intentionally safe, but models a plugin with reviewable behavior.",
|
||||
model: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
staticScan: scannedStaticScan,
|
||||
source: { kind: "github", repo: "openclaw/local-dev-fixture", path: "." },
|
||||
createdBy: userId,
|
||||
publishActor: { kind: "user", userId },
|
||||
createdAt: now,
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
await ctx.db.patch(scannedPackageId, {
|
||||
latestReleaseId: scannedPackageReleaseId,
|
||||
latestVersionSummary: {
|
||||
version: "0.1.0",
|
||||
createdAt: now,
|
||||
changelog: "Seeded public scanned release for plugin scanner page previews.",
|
||||
compatibility: { pluginApiRange: ">=0.1.0" },
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
runtimeId: "local.scanned.runtime",
|
||||
pluginKind: "runtime",
|
||||
capabilityTags: ["dev-tools", "security"],
|
||||
},
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
summary: "Local dev fixture completed security scans with reviewable findings.",
|
||||
sourceRepo: "openclaw/local-dev-fixture",
|
||||
scanStatus: "suspicious",
|
||||
},
|
||||
},
|
||||
tags: { latest: scannedPackageReleaseId },
|
||||
stats: { downloads: 7, installs: 1, stars: 1, versions: 1 },
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: 5,
|
||||
totalStars: 1,
|
||||
@@ -874,6 +1065,8 @@ export async function seedRescanUxFixturesHandler(
|
||||
flaggedSkillVersionId: skillVersionId,
|
||||
flaggedPluginId: packageId,
|
||||
flaggedPluginReleaseId: packageReleaseId,
|
||||
scannedPluginId: scannedPackageId,
|
||||
scannedPluginReleaseId: scannedPackageReleaseId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -884,10 +1077,92 @@ export const seedRescanUxFixturesMutation = internalMutation({
|
||||
flaggedSkillMd: v.string(),
|
||||
flaggedPluginStorageId: v.id("_storage"),
|
||||
flaggedPluginReadme: v.string(),
|
||||
scannedPluginStorageId: v.id("_storage"),
|
||||
scannedPluginReadme: v.string(),
|
||||
},
|
||||
handler: seedRescanUxFixturesHandler,
|
||||
});
|
||||
|
||||
export const seedCliRoleHelpFixtures = rawInternalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const now = Date.now();
|
||||
const admin = await upsertRoleHelpFixtureUser(ctx, {
|
||||
handle: "cli-admin",
|
||||
displayName: "CLI Admin",
|
||||
role: "admin",
|
||||
});
|
||||
const user = await upsertRoleHelpFixtureUser(ctx, {
|
||||
handle: "cli-user",
|
||||
displayName: "CLI User",
|
||||
role: "user",
|
||||
});
|
||||
|
||||
const adminToken = await replaceRoleHelpFixtureToken(ctx, admin._id, now);
|
||||
const userToken = await replaceRoleHelpFixtureToken(ctx, user._id, now);
|
||||
return {
|
||||
ok: true,
|
||||
admin: { handle: admin.handle, role: admin.role, token: adminToken },
|
||||
user: { handle: user.handle, role: user.role, token: userToken },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixtureUser) {
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", user.handle))
|
||||
.unique();
|
||||
const patch = {
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
return { ...existing, ...patch };
|
||||
}
|
||||
const userId = await ctx.db.insert("users", {
|
||||
...patch,
|
||||
createdAt: now,
|
||||
});
|
||||
const created = await ctx.db.get(userId);
|
||||
if (!created) throw new Error(`Failed to create ${user.handle}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function replaceRoleHelpFixtureToken(
|
||||
ctx: MutationCtx,
|
||||
userId: Id<"users">,
|
||||
now: number,
|
||||
) {
|
||||
const existingTokens = await ctx.db
|
||||
.query("apiTokens")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
for (const token of existingTokens) {
|
||||
if (token.label === "CLI role help e2e") {
|
||||
await ctx.db.patch(token._id, { revokedAt: now });
|
||||
}
|
||||
}
|
||||
|
||||
const { token, prefix } = generateToken();
|
||||
await ctx.db.insert("apiTokens", {
|
||||
userId,
|
||||
label: "CLI role help e2e",
|
||||
prefix,
|
||||
tokenHash: await hashToken(token),
|
||||
createdAt: now,
|
||||
lastUsedAt: undefined,
|
||||
revokedAt: undefined,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export const seedSkillMutation = internalMutation({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
|
||||
@@ -2114,6 +2114,7 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.user.handle).toBe("p");
|
||||
expect(json.user.role).toBeNull();
|
||||
});
|
||||
|
||||
it("delete and undelete require auth", async () => {
|
||||
@@ -2162,6 +2163,86 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
|
||||
it("skill rescan routes authenticated owners to the rescan mutation", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { handle: "p" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: "skill",
|
||||
name: args.slug,
|
||||
version: "1.2.3",
|
||||
status: "in_progress",
|
||||
remainingRequests: 2,
|
||||
maxRequests: 3,
|
||||
pendingRequestId: "rescanRequests:1",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/rescan", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
targetKind: "skill",
|
||||
name: "demo",
|
||||
remainingRequests: 2,
|
||||
maxRequests: 3,
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ actorUserId: "users:1", slug: "demo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("package rescan routes authenticated owners to the rescan mutation", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { handle: "p" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: "package",
|
||||
name: args.name,
|
||||
version: "1.2.3",
|
||||
status: "in_progress",
|
||||
remainingRequests: 2,
|
||||
maxRequests: 3,
|
||||
pendingRequestId: "rescanRequests:1",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/%40scope%2Fdemo/rescan", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
targetKind: "package",
|
||||
name: "@scope/demo",
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ actorUserId: "users:1", name: "@scope/demo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("transfer request requires auth", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
@@ -62,6 +62,7 @@ const internalRefs = internal as unknown as {
|
||||
getReleaseByPackageAndVersionInternal: unknown;
|
||||
getReleaseByIdInternal: unknown;
|
||||
insertAuditLogInternal: unknown;
|
||||
requestRescanForApiTokenInternal: unknown;
|
||||
softDeletePackageInternal: unknown;
|
||||
};
|
||||
packagePublishTokens: {
|
||||
@@ -823,6 +824,31 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request
|
||||
|
||||
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments[1] === "rescan" && segments.length === 2) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
try {
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.packages.requestRescanForApiTokenInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
name: segments[0]!,
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Rescan request failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
|
||||
return text("Not found", 404);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api, internal } from "../_generated/api";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
@@ -231,6 +231,16 @@ type SkillSecuritySnapshot = {
|
||||
};
|
||||
};
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
skills: {
|
||||
requestRescanForApiTokenInternal: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
|
||||
return (await ctx.runMutation(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
function isDefinitiveSecurityStatus(
|
||||
status: NormalizedSecurityStatus | null | undefined,
|
||||
): status is "clean" | "suspicious" | "malicious" {
|
||||
@@ -1165,6 +1175,29 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.length === 2 && action === "rescan") {
|
||||
if (!slug) return text("Slug required", 400, rate.headers);
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
try {
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.skills.requestRescanForApiTokenInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
slug,
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Rescan request failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "transfer") {
|
||||
return handleSkillsTransferPost(ctx, request, segments, rate.headers);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
|
||||
handle: user.handle ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
image: user.image ?? null,
|
||||
role: user.role ?? null,
|
||||
},
|
||||
},
|
||||
200,
|
||||
|
||||
@@ -2614,6 +2614,15 @@ describe("packages public queries", () => {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "rescanRequests") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
|
||||
+110
-11
@@ -11,7 +11,14 @@ import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { action, internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import {
|
||||
action,
|
||||
internalAction,
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from "./functions";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
@@ -48,10 +55,7 @@ import {
|
||||
import { tokenize } from "./lib/searchText";
|
||||
import { hashSkillFiles } from "./lib/skills";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import {
|
||||
getLatestPackageRescanTarget,
|
||||
insertPackageRescanRequest,
|
||||
} from "./model/packages/rescans";
|
||||
import { getLatestPackageRescanTarget, insertPackageRescanRequest } from "./model/packages/rescans";
|
||||
import {
|
||||
assertCanRequestRescan,
|
||||
buildRescanState,
|
||||
@@ -264,6 +268,7 @@ type DashboardPackageListItem = {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
pendingReview?: true;
|
||||
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
|
||||
latestRelease: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
@@ -434,6 +439,13 @@ async function toDashboardPackageListItem(
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
pendingReview: pkg.scanStatus === "pending" ? true : undefined,
|
||||
rescanState:
|
||||
latestRelease && !latestRelease.softDeletedAt
|
||||
? await buildRescanState(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: latestRelease._id,
|
||||
})
|
||||
: null,
|
||||
latestRelease:
|
||||
latestRelease && !latestRelease.softDeletedAt
|
||||
? {
|
||||
@@ -2226,7 +2238,9 @@ export const insertReleaseInternal = internalMutation({
|
||||
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
|
||||
.unique();
|
||||
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
|
||||
throw new ConvexError(`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`);
|
||||
throw new ConvexError(
|
||||
`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2664,11 +2678,14 @@ async function markPackageRescanRequest(
|
||||
status: "completed" | "failed",
|
||||
error?: string,
|
||||
) {
|
||||
await ctx.runMutation(internalRefs.rescanRequests.markStatusInternal as never, {
|
||||
requestId,
|
||||
status,
|
||||
error,
|
||||
} as never);
|
||||
await ctx.runMutation(
|
||||
internalRefs.rescanRequests.markStatusInternal as never,
|
||||
{
|
||||
requestId,
|
||||
status,
|
||||
error,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
|
||||
export const getRescanState = query({
|
||||
@@ -2696,6 +2713,39 @@ export const getRescanState = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getOwnerRescanStateByName = query({
|
||||
args: {
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const viewerUserId = await getOptionalViewerUserId(ctx);
|
||||
if (!viewerUserId) return null;
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || !pkg.latestReleaseId) return null;
|
||||
|
||||
const release = await ctx.db.get(pkg.latestReleaseId);
|
||||
if (!release || release.softDeletedAt) return null;
|
||||
|
||||
const actor = await ctx.db.get(viewerUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) return null;
|
||||
if (actor.role !== "admin") {
|
||||
const canAccess = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
|
||||
if (!canAccess) return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetKind: "plugin" as const,
|
||||
targetVersion: release.version,
|
||||
packageReleaseId: release._id,
|
||||
...(await buildRescanState(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: release._id,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescan = mutation({
|
||||
args: {
|
||||
packageId: v.id("packages"),
|
||||
@@ -2730,6 +2780,55 @@ export const requestRescan = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescanForApiTokenInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
throw new ConvexError("Plugin not found");
|
||||
}
|
||||
|
||||
const target = await getLatestPackageRescanTarget(ctx, pkg._id);
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor,
|
||||
ownerUserId: target.pkg.ownerUserId,
|
||||
ownerPublisherId: target.pkg.ownerPublisherId,
|
||||
allowPlatformAdmin: true,
|
||||
});
|
||||
await assertCanRequestRescan(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: target.release._id,
|
||||
});
|
||||
|
||||
const requestId = await insertPackageRescanRequest(ctx, actor, target);
|
||||
await ctx.scheduler.runAfter(0, internal.packages.dispatchPackageRescanInternal, {
|
||||
requestId,
|
||||
releaseId: target.release._id,
|
||||
});
|
||||
|
||||
const state = await buildRescanState(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: target.release._id,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: "package" as const,
|
||||
name: target.pkg.normalizedName,
|
||||
version: target.release.version,
|
||||
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
|
||||
remainingRequests: state.remainingRequests,
|
||||
maxRequests: state.maxRequests,
|
||||
pendingRequestId: requestId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const dispatchPackageRescanInternal = internalAction({
|
||||
args: {
|
||||
requestId: v.id("rescanRequests"),
|
||||
|
||||
@@ -1144,6 +1144,7 @@ type DashboardSkillListItem = {
|
||||
isSuspicious?: boolean;
|
||||
pendingReview?: true;
|
||||
qualityDecision?: NonNullable<Doc<"skills">["quality"]>["decision"];
|
||||
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
|
||||
latestVersion: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
@@ -1423,6 +1424,13 @@ async function toDashboardSkillListItem(
|
||||
? true
|
||||
: undefined,
|
||||
qualityDecision: skill.quality?.decision,
|
||||
rescanState:
|
||||
latestVersion && !latestVersion.softDeletedAt
|
||||
? await buildRescanState(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: latestVersion._id,
|
||||
})
|
||||
: null,
|
||||
latestVersion:
|
||||
latestVersion && !latestVersion.softDeletedAt
|
||||
? {
|
||||
@@ -4188,6 +4196,55 @@ export const requestRescan = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescanForApiTokenInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
slug: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
const resolved = await resolveSkillBySlugOrAlias(ctx, args.slug.trim().toLowerCase());
|
||||
const skill = resolved.skill;
|
||||
if (!skill) throw new ConvexError("Skill not found");
|
||||
|
||||
const target = await getLatestSkillRescanTarget(ctx, skill._id);
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor,
|
||||
ownerUserId: target.skill.ownerUserId,
|
||||
ownerPublisherId: target.skill.ownerPublisherId,
|
||||
allowPlatformAdmin: true,
|
||||
});
|
||||
await assertCanRequestRescan(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: target.version._id,
|
||||
});
|
||||
|
||||
const requestId = await insertSkillRescanRequest(ctx, actor, target);
|
||||
await ctx.scheduler.runAfter(0, internal.skills.dispatchSkillRescanInternal, {
|
||||
requestId,
|
||||
skillId: target.skill._id,
|
||||
versionId: target.version._id,
|
||||
});
|
||||
|
||||
const state = await buildRescanState(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: target.version._id,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: "skill" as const,
|
||||
name: target.skill.slug,
|
||||
version: target.version.version,
|
||||
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
|
||||
remainingRequests: state.remainingRequests,
|
||||
maxRequests: state.maxRequests,
|
||||
pendingRequestId: requestId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const dispatchSkillRescanInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
requestId: v.id("rescanRequests"),
|
||||
|
||||
@@ -33,6 +33,14 @@ function mustGetToken() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAdminToken() {
|
||||
return process.env.CLAWHUB_E2E_ADMIN_TOKEN?.trim() || null;
|
||||
}
|
||||
|
||||
function getUserToken() {
|
||||
return process.env.CLAWHUB_E2E_USER_TOKEN?.trim() || null;
|
||||
}
|
||||
|
||||
function getRegistry() {
|
||||
return (
|
||||
process.env.CLAWHUB_REGISTRY?.trim() ||
|
||||
@@ -73,7 +81,19 @@ function allowLiveMutations() {
|
||||
return value === "1" || value?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function shouldSeedRoleHelpTokens() {
|
||||
const value = process.env.CLAWHUB_E2E_SEED_CLI_ROLE_HELP?.trim();
|
||||
return value === "1" || value?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
const itIfLiveMutations = allowLiveMutations() ? it : it.skip;
|
||||
const itIfAdminAndUserTokens =
|
||||
getAdminToken() && getUserToken() || shouldSeedRoleHelpTokens() ? it : it.skip;
|
||||
|
||||
type RoleHelpTokens = {
|
||||
adminToken: string;
|
||||
userToken: string;
|
||||
};
|
||||
|
||||
async function makeTempConfig(registry: string, token: string | null) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "clawhub-e2e-"));
|
||||
@@ -86,6 +106,67 @@ async function makeTempConfig(registry: string, token: string | null) {
|
||||
return { dir, path };
|
||||
}
|
||||
|
||||
async function resolveRoleHelpTokens(registry: string): Promise<RoleHelpTokens> {
|
||||
const adminToken = getAdminToken();
|
||||
const userToken = getUserToken();
|
||||
if (adminToken && userToken) return { adminToken, userToken };
|
||||
|
||||
if (!shouldSeedRoleHelpTokens()) {
|
||||
throw new Error(
|
||||
"Missing CLAWHUB_E2E_ADMIN_TOKEN/CLAWHUB_E2E_USER_TOKEN or CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1",
|
||||
);
|
||||
}
|
||||
if (!isLocalRegistry(registry)) {
|
||||
throw new Error("CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1 only works against local registries");
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
"bunx",
|
||||
["convex", "run", "--no-push", "devSeed:seedCliRoleHelpFixtures"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to seed role help fixtures:\n${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(extractLastJsonObject(result.stdout)) as {
|
||||
admin?: { token?: unknown };
|
||||
user?: { token?: unknown };
|
||||
};
|
||||
if (typeof parsed.admin?.token !== "string" || typeof parsed.user?.token !== "string") {
|
||||
throw new Error("Role help fixture seed did not return admin and user tokens");
|
||||
}
|
||||
return { adminToken: parsed.admin.token, userToken: parsed.user.token };
|
||||
}
|
||||
|
||||
function isLocalRegistry(registry: string) {
|
||||
try {
|
||||
const hostname = new URL(registry).hostname;
|
||||
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractLastJsonObject(output: string) {
|
||||
const trimmed = output.trim();
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
if (trimmed[index] !== "{") continue;
|
||||
const candidate = trimmed.slice(index);
|
||||
try {
|
||||
JSON.parse(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Keep scanning for the actual JSON payload if Convex printed status lines first.
|
||||
}
|
||||
}
|
||||
throw new Error(`No JSON object in convex run output:\n${output}`);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), REQUEST_TIMEOUT_MS);
|
||||
@@ -197,6 +278,63 @@ describe("clawhub e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
itIfAdminAndUserTokens("shows staff CLI commands only in admin help", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const { adminToken, userToken } = await resolveRoleHelpTokens(registry);
|
||||
|
||||
async function expectRole(token: string, expectedRole: "admin" | "user") {
|
||||
const whoamiUrl = new URL(ApiRoutes.whoami, registry);
|
||||
const response = await fetchWithTimeout(whoamiUrl.toString(), {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
const whoami = parseArk(
|
||||
ApiV1WhoamiResponseSchema,
|
||||
(await response.json()) as unknown,
|
||||
"Whoami",
|
||||
);
|
||||
expect(whoami.user.role).toBe(expectedRole);
|
||||
}
|
||||
|
||||
await expectRole(adminToken, "admin");
|
||||
await expectRole(userToken, "user");
|
||||
|
||||
const adminCfg = await makeTempConfig(registry, adminToken);
|
||||
const userCfg = await makeTempConfig(registry, userToken);
|
||||
try {
|
||||
const baseEnv = { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" };
|
||||
const adminResult = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "--registry", registry, "--site", site, "--help"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: adminCfg.path },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const userResult = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "--registry", registry, "--site", site, "--help"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: userCfg.path },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(adminResult.status).toBe(0);
|
||||
expect(adminResult.stdout).toContain("ban-user");
|
||||
expect(adminResult.stdout).toContain("set-role");
|
||||
expect(userResult.status).toBe(0);
|
||||
expect(userResult.stdout).not.toContain("ban-user");
|
||||
expect(userResult.stdout).not.toContain("set-role");
|
||||
} finally {
|
||||
await rm(adminCfg.dir, { recursive: true, force: true });
|
||||
await rm(userCfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from an explicit root", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { shouldShowAdminCommandsInHelp } from "./cli/adminHelp.js";
|
||||
import { getCliBuildLabel, getCliVersion } from "./cli/buildInfo.js";
|
||||
import { resolveClawdbotDefaultWorkspace } from "./cli/clawdbotConfig.js";
|
||||
import { cmdLoginFlow, cmdLogout, cmdWhoami } from "./cli/commands/auth.js";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
cmdSetPackageTrustedPublisher,
|
||||
} from "./cli/commands/packages.js";
|
||||
import { cmdPublish } from "./cli/commands/publish.js";
|
||||
import { cmdRescanPackage, cmdRescanSkill } from "./cli/commands/rescan.js";
|
||||
import {
|
||||
cmdExplore,
|
||||
cmdInstall,
|
||||
@@ -47,6 +49,9 @@ import type { GlobalOpts } from "./cli/types.js";
|
||||
import { fail } from "./cli/ui.js";
|
||||
import { readGlobalConfig } from "./config.js";
|
||||
|
||||
const showAdminCommandsInHelp = await shouldShowAdminCommandsInHelp();
|
||||
const adminCommandOptions = showAdminCommandsInHelp ? undefined : { hidden: true };
|
||||
|
||||
const program = new Command()
|
||||
.name("clawhub")
|
||||
.description(
|
||||
@@ -451,6 +456,17 @@ trustedPublisherCmd
|
||||
await cmdDeletePackageTrustedPublisher(opts, name, options);
|
||||
});
|
||||
|
||||
packageCmd
|
||||
.command("rescan")
|
||||
.description("Request a security rescan for the latest published package release")
|
||||
.argument("<name>", "Package name")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdRescanPackage(opts, name, options, isInputAllowed());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("rename")
|
||||
.description("Rename a published skill and keep the old slug as a redirect")
|
||||
@@ -473,8 +489,19 @@ skill
|
||||
await cmdMergeSkill(opts, sourceSlug, targetSlug, options, isInputAllowed());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("rescan")
|
||||
.description("Request a security rescan for the latest published skill version")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdRescanSkill(opts, slug, options, isInputAllowed());
|
||||
});
|
||||
|
||||
program
|
||||
.command("ban-user")
|
||||
.command("ban-user", adminCommandOptions)
|
||||
.description("Ban a user and delete owned skills (moderator/admin only)")
|
||||
.argument("<handleOrId>", "User handle (default) or user id")
|
||||
.option("--id", "Treat argument as user id")
|
||||
@@ -487,7 +514,7 @@ program
|
||||
});
|
||||
|
||||
program
|
||||
.command("set-role")
|
||||
.command("set-role", adminCommandOptions)
|
||||
.description("Change a user role (admin only)")
|
||||
.argument("<handleOrId>", "User handle (default) or user id")
|
||||
.argument("<role>", "user | moderator | admin")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockReadGlobalConfig = vi.fn(
|
||||
async () => null as { registry?: string; token?: string } | null,
|
||||
);
|
||||
vi.mock("../config.js", () => ({
|
||||
readGlobalConfig: () => mockReadGlobalConfig(),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
vi.mock("../http.js", () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
}));
|
||||
|
||||
const { isHelpRequest, shouldShowAdminCommandsInHelp } = await import("./adminHelp");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("admin help gating", () => {
|
||||
it("treats root help-like invocations as help requests", () => {
|
||||
expect(isHelpRequest(["node", "clawhub", "--help"])).toBe(true);
|
||||
expect(isHelpRequest(["node", "clawhub", "help"])).toBe(true);
|
||||
expect(isHelpRequest(["node", "clawhub"])).toBe(true);
|
||||
expect(isHelpRequest(["node", "clawhub", "search", "weather"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not hide commands for normal command execution", async () => {
|
||||
await expect(
|
||||
shouldShowAdminCommandsInHelp({
|
||||
argv: ["node", "clawhub", "ban-user", "demo"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(mockReadGlobalConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides admin commands from help when logged out", async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
shouldShowAdminCommandsInHelp({
|
||||
argv: ["node", "clawhub", "--help"],
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(mockApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows admin commands in help for stored admin tokens", async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({
|
||||
registry: "https://registry.example",
|
||||
token: "clh_admin",
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
user: { handle: "p", role: "admin" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
shouldShowAdminCommandsInHelp({
|
||||
argv: ["node", "clawhub", "--help"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
"https://registry.example",
|
||||
expect.objectContaining({ path: "/api/v1/whoami", token: "clh_admin" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides admin commands in help for non-admin tokens", async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({
|
||||
registry: "https://registry.example",
|
||||
token: "clh_user",
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
user: { handle: "p", role: "moderator" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
shouldShowAdminCommandsInHelp({
|
||||
argv: ["node", "clawhub", "--help"],
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readGlobalConfig } from "../config.js";
|
||||
import { apiRequest } from "../http.js";
|
||||
import { ApiRoutes, ApiV1WhoamiResponseSchema } from "../schema/index.js";
|
||||
import { DEFAULT_REGISTRY } from "./registry.js";
|
||||
|
||||
type AdminHelpDeps = {
|
||||
argv?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
export function isHelpRequest(argv: string[] = process.argv) {
|
||||
const args = argv.slice(2);
|
||||
return args.length === 0 || args[0] === "help" || args.includes("--help") || args.includes("-h");
|
||||
}
|
||||
|
||||
export async function shouldShowAdminCommandsInHelp(deps: AdminHelpDeps = {}) {
|
||||
if (!isHelpRequest(deps.argv)) return true;
|
||||
|
||||
const env = deps.env ?? process.env;
|
||||
const cfg = await readGlobalConfig();
|
||||
const token = cfg?.token?.trim();
|
||||
if (!token) return false;
|
||||
|
||||
const registry =
|
||||
readFlagValue(deps.argv ?? process.argv, "--registry")?.trim() ||
|
||||
env.CLAWHUB_REGISTRY?.trim() ||
|
||||
env.CLAWDHUB_REGISTRY?.trim() ||
|
||||
cfg?.registry?.trim() ||
|
||||
DEFAULT_REGISTRY;
|
||||
|
||||
try {
|
||||
const whoami = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", path: ApiRoutes.whoami, token },
|
||||
ApiV1WhoamiResponseSchema,
|
||||
);
|
||||
return whoami.user.role === "admin";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readFlagValue(argv: string[], flag: string) {
|
||||
const args = argv.slice(2);
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === flag) return args[index + 1];
|
||||
if (arg?.startsWith(`${flag}=`)) return arg.slice(flag.length + 1);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdRescanPackage, cmdRescanSkill } = await import("./rescan");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const response = {
|
||||
ok: true,
|
||||
targetKind: "skill",
|
||||
name: "demo",
|
||||
version: "1.2.3",
|
||||
status: "in_progress",
|
||||
remainingRequests: 2,
|
||||
maxRequests: 3,
|
||||
pendingRequestId: "rescanRequests:1",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
});
|
||||
|
||||
describe("rescan commands", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdRescanSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdRescanPackage(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts skill rescans to the skill rescan endpoint", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(response);
|
||||
|
||||
await cmdRescanSkill(makeGlobalOpts(), "Demo", { yes: true }, false);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/rescan" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
|
||||
"OK. Requested skill rescan for demo@1.2.3 (2/3 remaining)",
|
||||
);
|
||||
});
|
||||
|
||||
it("posts package rescans to the package rescan endpoint", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
...response,
|
||||
targetKind: "package",
|
||||
name: "@scope/demo",
|
||||
});
|
||||
|
||||
await cmdRescanPackage(makeGlobalOpts(), "@scope/demo", { yes: true }, false);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/%40scope%2Fdemo/rescan",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints JSON output", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(response);
|
||||
|
||||
await cmdRescanSkill(makeGlobalOpts(), "demo", { yes: true, json: true }, false);
|
||||
|
||||
expect(uiMocks.spinner.stop).toHaveBeenCalled();
|
||||
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(response, null, 2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { apiRequest } from "../../http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1RescanResponseSchema,
|
||||
parseArk,
|
||||
type ApiV1RescanResponse,
|
||||
} from "../../schema/index.js";
|
||||
import { requireAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import type { GlobalOpts } from "../types.js";
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
|
||||
|
||||
type RescanTargetKind = "skill" | "package";
|
||||
|
||||
type RescanOptions = {
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export async function cmdRescanSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: RescanOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase();
|
||||
if (!slug) fail("Slug required");
|
||||
return requestRescan(opts, "skill", slug, options, inputAllowed);
|
||||
}
|
||||
|
||||
export async function cmdRescanPackage(
|
||||
opts: GlobalOpts,
|
||||
nameArg: string,
|
||||
options: RescanOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const name = nameArg.trim();
|
||||
if (!name) fail("Package name required");
|
||||
return requestRescan(opts, "package", name, options, inputAllowed);
|
||||
}
|
||||
|
||||
async function requestRescan(
|
||||
opts: GlobalOpts,
|
||||
targetKind: RescanTargetKind,
|
||||
name: string,
|
||||
options: RescanOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false;
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail("Pass --yes (no input)");
|
||||
const ok = await promptConfirm(`Request latest ${targetKind} rescan for ${name}?`);
|
||||
if (!ok) return undefined;
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = createSpinner(`Requesting ${targetKind} rescan for ${name}`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: buildRescanPath(targetKind, name),
|
||||
token,
|
||||
},
|
||||
ApiV1RescanResponseSchema,
|
||||
);
|
||||
const parsed = parseArk(ApiV1RescanResponseSchema, result, "Rescan response");
|
||||
if (options.json) {
|
||||
spinner.stop();
|
||||
console.log(JSON.stringify(parsed, null, 2));
|
||||
} else {
|
||||
spinner.succeed(formatRescanSuccess(parsed));
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRescanPath(targetKind: RescanTargetKind, name: string) {
|
||||
const root = targetKind === "skill" ? ApiRoutes.skills : ApiRoutes.packages;
|
||||
return `${root}/${encodeURIComponent(name)}/rescan`;
|
||||
}
|
||||
|
||||
function formatRescanSuccess(result: ApiV1RescanResponse) {
|
||||
const label = result.targetKind === "skill" ? "skill" : "package";
|
||||
return `OK. Requested ${label} rescan for ${result.name}@${result.version} (${result.remainingRequests}/${result.maxRequests} remaining)`;
|
||||
}
|
||||
@@ -134,6 +134,7 @@ export const ApiV1WhoamiResponseSchema = type({
|
||||
handle: "string|null",
|
||||
displayName: "string|null?",
|
||||
image: "string|null?",
|
||||
role: '"admin"|"moderator"|"user"|null?',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -273,6 +274,18 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
});
|
||||
|
||||
export const ApiV1RescanResponseSchema = type({
|
||||
ok: "true",
|
||||
targetKind: '"skill"|"package"',
|
||||
name: "string",
|
||||
version: "string",
|
||||
status: '"in_progress"|"completed"|"failed"',
|
||||
remainingRequests: "number",
|
||||
maxRequests: "number",
|
||||
pendingRequestId: "string?",
|
||||
});
|
||||
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillRenameResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
|
||||
Vendored
+1
@@ -134,6 +134,7 @@ export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variant
|
||||
handle: string | null;
|
||||
displayName?: string | null | undefined;
|
||||
image?: string | null | undefined;
|
||||
role?: "user" | "admin" | "moderator" | null | undefined;
|
||||
};
|
||||
}, {}>;
|
||||
export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
|
||||
Vendored
+1
@@ -110,6 +110,7 @@ export const ApiV1WhoamiResponseSchema = type({
|
||||
handle: "string|null",
|
||||
displayName: "string|null?",
|
||||
image: "string|null?",
|
||||
role: '"admin"|"moderator"|"user"|null?',
|
||||
},
|
||||
});
|
||||
export const ApiV1UserSearchResponseSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -135,6 +135,7 @@ export const ApiV1WhoamiResponseSchema = type({
|
||||
handle: "string|null",
|
||||
displayName: "string|null?",
|
||||
image: "string|null?",
|
||||
role: '"admin"|"moderator"|"user"|null?',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -282,6 +283,18 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
});
|
||||
|
||||
export const ApiV1RescanResponseSchema = type({
|
||||
ok: "true",
|
||||
targetKind: '"skill"|"package"',
|
||||
name: "string",
|
||||
version: "string",
|
||||
status: '"in_progress"|"completed"|"failed"',
|
||||
remainingRequests: "number",
|
||||
maxRequests: "number",
|
||||
pendingRequestId: "string?",
|
||||
});
|
||||
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillRenameResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("Header", () => {
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders direct desktop theme family controls and plain Skills tab", () => {
|
||||
it("renders simplified desktop nav and theme toggle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
@@ -132,22 +132,27 @@ describe("Header", () => {
|
||||
render(<Header />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Cycle theme mode/i }),
|
||||
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(1);
|
||||
expect(screen.queryByText("Users")).toBeNull();
|
||||
expect(screen.queryByText("Dashboard")).toBeNull();
|
||||
expect(screen.queryByText("Manage")).toBeNull();
|
||||
expect(
|
||||
screen.getByPlaceholderText("Search skills, plugins, users"),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("light");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
|
||||
);
|
||||
expect(setModeMock).toHaveBeenCalledWith("dark");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
|
||||
@@ -15,17 +15,17 @@ const isRateLimitedPackageApiErrorMock = vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
|
||||
);
|
||||
const useQueryMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
readme: string | null;
|
||||
rateLimited:
|
||||
| {
|
||||
scope: "detail" | "metadata";
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
| null;
|
||||
rateLimited: {
|
||||
scope: "detail" | "metadata";
|
||||
retryAfterSeconds: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
let paramsMock = { name: "demo-plugin" };
|
||||
@@ -59,6 +59,14 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
useParams: () => paramsMock,
|
||||
useLoaderData: () => loaderDataMock,
|
||||
}),
|
||||
useRouterState: ({
|
||||
select,
|
||||
}: {
|
||||
select?: (state: { location: { pathname: string } }) => string;
|
||||
}) =>
|
||||
select
|
||||
? select({ location: { pathname: `/plugins/${paramsMock.name}` } })
|
||||
: `/plugins/${paramsMock.name}`,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
@@ -73,6 +81,15 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/packageApi", () => ({
|
||||
fetchPackageDetail: vi.fn(),
|
||||
fetchPackageReadme: vi.fn(),
|
||||
@@ -86,7 +103,13 @@ vi.mock("../lib/packageApi", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../components/MarkdownPreview", () => ({
|
||||
MarkdownPreview: ({ children }: { children: string; className?: string; highlight?: boolean }) => <div>{children}</div>,
|
||||
MarkdownPreview: ({
|
||||
children,
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
highlight?: boolean;
|
||||
}) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
@@ -128,6 +151,14 @@ describe("plugin detail route", () => {
|
||||
rateLimited: null,
|
||||
};
|
||||
isRateLimitedPackageApiErrorMock.mockClear();
|
||||
useQueryMock.mockReset();
|
||||
useQueryMock.mockReturnValue(undefined);
|
||||
useAuthStatusMock.mockReset();
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides download actions when the plugin has no latest release", async () => {
|
||||
@@ -188,9 +219,66 @@ describe("plugin detail route", () => {
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("Security Scan")).toBeTruthy();
|
||||
expect(screen.getByText("Security Scans")).toBeTruthy();
|
||||
expect(screen.getAllByText("VirusTotal").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("OpenClaw").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("ClawScan").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("link", { name: /VirusTotal.*Benign/i }).getAttribute("href")).toBe(
|
||||
"/plugins/demo-plugin/security/virustotal",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Static analysis.*Benign/i }).getAttribute("href"),
|
||||
).toBe("/plugins/demo-plugin/security/static-analysis");
|
||||
});
|
||||
|
||||
it("shows owner-only plugin rescan state in the security summary", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:1" },
|
||||
});
|
||||
useQueryMock.mockReturnValue({
|
||||
maxRequests: 3,
|
||||
requestCount: 1,
|
||||
remainingRequests: 2,
|
||||
canRequest: true,
|
||||
inProgressRequest: null,
|
||||
latestRequest: null,
|
||||
});
|
||||
loaderDataMock = {
|
||||
detail: loaderDataMock.detail,
|
||||
version: {
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [],
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: null,
|
||||
sha256hash: "a".repeat(64),
|
||||
vtAnalysis: null,
|
||||
llmAnalysis: null,
|
||||
staticScan: null,
|
||||
},
|
||||
},
|
||||
readme: null,
|
||||
rateLimited: null,
|
||||
};
|
||||
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Rescan" })).toBeTruthy();
|
||||
expect(screen.queryByText("Owner rescan")).toBeNull();
|
||||
expect(screen.queryByText("2/3 rescans left")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a retryable empty state when the detail lookup is rate limited", async () => {
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("SkillDetailPage", () => {
|
||||
});
|
||||
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/Skill not found/i)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -222,18 +222,105 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const installHeading = await screen.findByRole("heading", { name: "Install with OpenClaw" });
|
||||
const scanDisclaimer = screen.getByText(
|
||||
/Like a lobster shell, security has layers — review code before you run it\./i,
|
||||
await screen.findByRole("heading", { name: "Install" });
|
||||
const securityHeading = screen.getByRole("heading", { name: "Security Scans" });
|
||||
|
||||
expect(screen.getAllByRole("heading", { name: "Install" }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
|
||||
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "ClawHub" })).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("tab", { name: "Prompt" })).toBeTruthy();
|
||||
expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull();
|
||||
expect(securityHeading).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /VirusTotal.*Pending/i })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /ClawScan.*Pending/i })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /Static analysis.*Pending/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/Like a lobster shell, security has layers/i)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Rescan" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an owner rescan action in the security summary for owned skills", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: ownerId, role: "user" },
|
||||
});
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args && !("limit" in args)) {
|
||||
return {
|
||||
maxRequests: 3,
|
||||
requestCount: 1,
|
||||
remainingRequests: 2,
|
||||
canRequest: true,
|
||||
inProgressRequest: null,
|
||||
latestRequest: null,
|
||||
};
|
||||
}
|
||||
if (args && typeof args === "object" && "limit" in args) return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="weather"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: { license: "MIT-0", frontmatter: {} },
|
||||
files: [],
|
||||
sha256hash: "abc123",
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# Weather",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
|
||||
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
|
||||
expect(screen.getByText("npx clawhub@latest install weather")).toBeTruthy();
|
||||
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
|
||||
expect(
|
||||
installHeading.compareDocumentPosition(scanDisclaimer) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole("button", { name: "Rescan" })).toBeTruthy();
|
||||
expect(screen.queryByText("Owner rescan")).toBeNull();
|
||||
expect(screen.queryByText("2/3 rescans left")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not refetch readme when SSR data already matches the latest version", async () => {
|
||||
@@ -349,7 +436,7 @@ describe("SkillDetailPage", () => {
|
||||
});
|
||||
|
||||
render(<SkillDetailPage slug="weather" redirectToCanonical />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
@@ -506,7 +593,7 @@ describe("SkillDetailPage", () => {
|
||||
expect(screen.getByText(/Report skill/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows owner tools for the skill owner", async () => {
|
||||
it("links owner tools from the detail page and renders them on settings", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -514,7 +601,11 @@ describe("SkillDetailPage", () => {
|
||||
});
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "ownerUserId" in args) {
|
||||
if (
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
("ownerUserId" in args || "ownerPublisherId" in args)
|
||||
) {
|
||||
return [
|
||||
{ _id: "skills:1", slug: "weather", displayName: "Weather" },
|
||||
{ _id: "skills:2", slug: "weather-pro", displayName: "Weather Pro" },
|
||||
@@ -547,7 +638,14 @@ describe("SkillDetailPage", () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
const { unmount } = render(<SkillDetailPage slug="weather" />);
|
||||
|
||||
const settingsLink = await screen.findByRole("link", { name: /settings/i });
|
||||
expect(settingsLink.getAttribute("href")).toBe("/steipete/weather/settings");
|
||||
expect(screen.queryByText(/Owner tools/i)).toBeNull();
|
||||
unmount();
|
||||
|
||||
render(<SkillDetailPage slug="weather" mode="settings" />);
|
||||
|
||||
expect(await screen.findByText(/Owner tools/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Rename and redirect/i })).toBeTruthy();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type DetailPageShellProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type DetailHeroProps = {
|
||||
main: ReactNode;
|
||||
sidebar?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
topClassName?: string;
|
||||
mainClassName?: string;
|
||||
sidebarClassName?: string;
|
||||
};
|
||||
|
||||
type DetailBodyProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
mainClassName?: string;
|
||||
};
|
||||
|
||||
export function DetailPageShell({ children, className }: DetailPageShellProps) {
|
||||
return <div className={cn("skill-detail-stack", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function DetailHero({
|
||||
main,
|
||||
sidebar,
|
||||
children,
|
||||
className,
|
||||
topClassName,
|
||||
mainClassName,
|
||||
sidebarClassName,
|
||||
}: DetailHeroProps) {
|
||||
return (
|
||||
<div className={cn("skill-hero", className)}>
|
||||
<div className={cn("skill-hero-top", topClassName)}>
|
||||
<div className="skill-hero-layout">
|
||||
<div className={cn("skill-hero-main", mainClassName)}>
|
||||
{main}
|
||||
{children ? <div className="skill-hero-main-extra">{children}</div> : null}
|
||||
</div>
|
||||
{sidebar ? (
|
||||
<aside className={cn("skill-hero-sidebar", sidebarClassName)}>{sidebar}</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailBody({ children, className, mainClassName }: DetailBodyProps) {
|
||||
return (
|
||||
<div className={cn("detail-layout", className)}>
|
||||
<div className={cn("detail-main", mainClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
getScanStatusInfo,
|
||||
type LlmAnalysis,
|
||||
type StaticFinding,
|
||||
type VtAnalysis,
|
||||
} from "./SkillSecurityScanResults";
|
||||
import { Badge, type BadgeProps } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
|
||||
type RescanRequest = {
|
||||
_id: string;
|
||||
targetKind: "skill" | "plugin";
|
||||
targetVersion: string;
|
||||
status: "in_progress" | "completed" | "failed";
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt?: number;
|
||||
};
|
||||
|
||||
export type DetailRescanState = {
|
||||
maxRequests: number;
|
||||
requestCount: number;
|
||||
remainingRequests: number;
|
||||
canRequest: boolean;
|
||||
inProgressRequest: RescanRequest | null;
|
||||
latestRequest: RescanRequest | null;
|
||||
};
|
||||
|
||||
type DetailSecuritySummaryProps = {
|
||||
scannerBasePath: string;
|
||||
sha256hash?: string | null;
|
||||
vtAnalysis?: VtAnalysis | null;
|
||||
llmAnalysis?: LlmAnalysis | null;
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: StaticFinding[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
rescanState?: DetailRescanState | null;
|
||||
onRequestRescan?: (() => Promise<void>) | null;
|
||||
};
|
||||
|
||||
function statusFromStaticScan(staticScan: DetailSecuritySummaryProps["staticScan"]) {
|
||||
if (staticScan?.status) return staticScan.status;
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function badgeVariantForScanStatus(status: string): BadgeProps["variant"] {
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === "clean" || normalized === "benign") return "success";
|
||||
if (normalized === "suspicious") return "warning";
|
||||
if (normalized === "malicious" || normalized === "error") return "destructive";
|
||||
if (normalized === "pending" || normalized === "queued" || normalized === "loading") {
|
||||
return "pending";
|
||||
}
|
||||
return "compact";
|
||||
}
|
||||
|
||||
function ScannerRow({ href, label, status }: { href: string; label: string; status: string }) {
|
||||
const info = getScanStatusInfo(status);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="flex min-w-0 items-center justify-between gap-3 rounded-[var(--radius-sm)] px-1 py-2 text-sm !no-underline hover:bg-[color:var(--surface-muted)] hover:!no-underline"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 font-semibold text-[color:var(--ink)]">
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={badgeVariantForScanStatus(status)}>{info.label}</Badge>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function rescanDisabledReason(state: DetailRescanState | null | undefined) {
|
||||
if (!state) return null;
|
||||
if (state.inProgressRequest) return "A rescan is already in progress.";
|
||||
if (state.remainingRequests <= 0) {
|
||||
return `Rescan limit reached (${state.requestCount}/${state.maxRequests}).`;
|
||||
}
|
||||
if (!state.canRequest) return "This release is not eligible for another rescan.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DetailSecuritySummary({
|
||||
scannerBasePath,
|
||||
vtAnalysis,
|
||||
llmAnalysis,
|
||||
staticScan,
|
||||
rescanState,
|
||||
onRequestRescan,
|
||||
}: DetailSecuritySummaryProps) {
|
||||
const [isRequestingRescan, setIsRequestingRescan] = useState(false);
|
||||
const vtStatus = vtAnalysis?.verdict ?? vtAnalysis?.status ?? "pending";
|
||||
const llmStatus = llmAnalysis?.verdict ?? llmAnalysis?.status ?? "pending";
|
||||
const staticStatus = statusFromStaticScan(staticScan);
|
||||
const rescanButtonDisabledReason = rescanDisabledReason(rescanState);
|
||||
|
||||
async function handleRequestRescan() {
|
||||
if (!onRequestRescan || rescanButtonDisabledReason || isRequestingRescan) return;
|
||||
setIsRequestingRescan(true);
|
||||
try {
|
||||
await onRequestRescan();
|
||||
} finally {
|
||||
setIsRequestingRescan(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Security Scans
|
||||
{rescanState && onRequestRescan ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
loading={isRequestingRescan}
|
||||
disabled={Boolean(rescanButtonDisabledReason)}
|
||||
title={rescanButtonDisabledReason ?? "Request a fresh scan"}
|
||||
onClick={() => void handleRequestRescan()}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ScannerRow href={`${scannerBasePath}/virustotal`} label="VirusTotal" status={vtStatus} />
|
||||
<ScannerRow href={`${scannerBasePath}/openclaw`} label="ClawScan" status={llmStatus} />
|
||||
<ScannerRow
|
||||
href={`${scannerBasePath}/static-analysis`}
|
||||
label="Static analysis"
|
||||
status={staticStatus}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+65
-142
@@ -1,19 +1,17 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useMemo, useRef, useState } from "react";
|
||||
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useMemo, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
filterNavItems,
|
||||
type NavIconName,
|
||||
PRIMARY_NAV_ITEMS,
|
||||
SECONDARY_NAV_ITEMS,
|
||||
} from "../lib/nav-items";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
|
||||
import { applyTheme, useThemeMode } from "../lib/theme";
|
||||
import { startThemeTransition } from "../lib/theme-transition";
|
||||
import { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -32,7 +30,6 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./ui/sheet";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
|
||||
wrench: Wrench,
|
||||
@@ -40,13 +37,10 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
|
||||
ghost: Ghost,
|
||||
};
|
||||
|
||||
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const { theme, mode, setMode } = useThemeMode();
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null);
|
||||
const siteMode = getSiteMode();
|
||||
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
|
||||
const isSoulMode = siteMode === "souls";
|
||||
@@ -64,7 +58,6 @@ export default function Header() {
|
||||
[hasResolvedUser, isSoulMode, isStaff],
|
||||
);
|
||||
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
@@ -72,24 +65,11 @@ export default function Header() {
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const ThemeModeIcon = getThemeModeIcon(mode);
|
||||
const nextThemeMode = getNextThemeMode(mode);
|
||||
|
||||
const setThemeMode = (next: "system" | "light" | "dark") => {
|
||||
startThemeTransition({
|
||||
nextTheme: next,
|
||||
currentTheme: mode,
|
||||
setTheme: (value) => {
|
||||
const nextMode = value as "system" | "light" | "dark";
|
||||
applyTheme(nextMode, theme);
|
||||
setMode(nextMode);
|
||||
},
|
||||
context: { element: toggleRef.current },
|
||||
});
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
|
||||
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
|
||||
setThemeMode(nextMode);
|
||||
applyTheme(next, theme);
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
const handleNavSearch = (e: React.FormEvent) => {
|
||||
@@ -166,48 +146,19 @@ export default function Header() {
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
{secondaryItems.map((item) => (
|
||||
<SheetClose key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-nav-section">
|
||||
<div className="mobile-nav-section-title">Theme mode</div>
|
||||
<div className="mobile-nav-section-title">Theme</div>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("system");
|
||||
setThemeMode(nextThemeMode);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("light");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("dark");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
Dark
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
{mode === "system" ? "System theme" : `${mode} theme`}
|
||||
</button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
@@ -237,6 +188,32 @@ export default function Header() {
|
||||
/>
|
||||
</form>
|
||||
|
||||
<nav className="navbar-top-links" aria-label="Primary">
|
||||
{isSoulMode ? (
|
||||
<a href={clawHubUrl} className="navbar-tab">
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{primaryItems.map((item) => {
|
||||
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix),
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
className="navbar-tab"
|
||||
search={item.search ?? {}}
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="nav-actions">
|
||||
<button
|
||||
className="navbar-search-mobile-trigger"
|
||||
@@ -246,41 +223,16 @@ export default function Header() {
|
||||
>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="theme-toggle" ref={toggleRef}>
|
||||
<div className="theme-cycle-group" aria-label="Theme controls">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-cycle-button theme-cycle-button-mode"
|
||||
onClick={cycleThemeMode}
|
||||
aria-label={`Cycle theme mode. Current: ${mode}`}
|
||||
title={`Theme mode: ${mode}`}
|
||||
>
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
className="theme-mode-toggle"
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setThemeMode(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
<div className="theme-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-cycle-button"
|
||||
onClick={() => setThemeMode(nextThemeMode)}
|
||||
aria-label={`Toggle theme. Current: ${mode}`}
|
||||
title={`Theme: ${mode}`}
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">System</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Light</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Dark</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{isAuthenticated && me ? (
|
||||
<DropdownMenu>
|
||||
@@ -296,6 +248,9 @@ export default function Header() {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/stars">Stars</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -336,9 +291,7 @@ export default function Header() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Github size={16} aria-hidden="true" />
|
||||
<span className="sign-in-label">Sign in</span>
|
||||
<span className="sign-in-provider">with GitHub</span>
|
||||
Sign In
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -360,52 +313,6 @@ export default function Header() {
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Content type tabs */}
|
||||
<nav className="navbar-tabs" aria-label="Content types">
|
||||
<div className="navbar-tabs-primary">
|
||||
{isSoulMode ? (
|
||||
<a href={clawHubUrl} className="navbar-tab">
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{primaryItems.map((item) => {
|
||||
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
className="navbar-tab"
|
||||
search={item.search ?? {}}
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="navbar-tabs-secondary">
|
||||
{secondaryItems.map((item) => {
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
search={item.search ?? {}}
|
||||
className="navbar-tab navbar-tab-secondary"
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@@ -424,6 +331,22 @@ function getThemeModeIcon(mode: "system" | "light" | "dark") {
|
||||
return Moon;
|
||||
case "system":
|
||||
default:
|
||||
return Monitor;
|
||||
return Sun;
|
||||
}
|
||||
}
|
||||
|
||||
function getResolvedThemeMode(): "light" | "dark" {
|
||||
if (typeof document !== "undefined") {
|
||||
const resolved = document.documentElement.dataset.themeResolved;
|
||||
if (resolved === "light" || resolved === "dark") return resolved;
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
function getNextThemeMode(mode: "system" | "light" | "dark"): "light" | "dark" {
|
||||
const resolved = mode === "system" ? getResolvedThemeMode() : mode;
|
||||
return resolved === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
@@ -37,11 +37,13 @@ export function InstallCopyButton({
|
||||
label = "Copy",
|
||||
ariaLabel,
|
||||
className,
|
||||
showLabel = true,
|
||||
}: {
|
||||
text: string;
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}) {
|
||||
const [copyState, setCopyState] = useState<CopyState>("idle");
|
||||
const resetTimeoutRef = useRef<number | null>(null);
|
||||
@@ -94,7 +96,7 @@ export function InstallCopyButton({
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span aria-live="polite">{buttonLabel}</span>
|
||||
{showLabel ? <span aria-live="polite">{buttonLabel}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { useQuery } from "convex/react";
|
||||
import { AlertTriangle, ArrowLeft, Clock, ExternalLink, Fingerprint, RotateCw } from "lucide-react";
|
||||
import { ArrowLeft, Clock, ExternalLink, Fingerprint } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
getScanStatusInfo,
|
||||
type LlmAnalysis,
|
||||
OpenClawIcon,
|
||||
type StaticFinding,
|
||||
type VtAnalysis,
|
||||
VirusTotalIcon,
|
||||
} from "./SkillSecurityScanResults";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -33,7 +27,6 @@ type EntityRef = {
|
||||
ownerUserId?: Id<"users"> | null;
|
||||
ownerPublisherId?: Id<"publishers"> | null;
|
||||
detailPath: string;
|
||||
securityBasePath: string;
|
||||
};
|
||||
|
||||
type SecurityScannerPageProps = {
|
||||
@@ -55,22 +48,16 @@ type SecurityScannerPageProps = {
|
||||
|
||||
const SCANNER_LABELS: Record<ScannerSlug, string> = {
|
||||
virustotal: "VirusTotal",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw: "ClawScan",
|
||||
"static-analysis": "Static analysis",
|
||||
};
|
||||
|
||||
const SCANNER_SUMMARIES: Record<ScannerSlug, string> = {
|
||||
virustotal: "External malware reputation and Code Insight signals for this exact artifact hash.",
|
||||
openclaw: "OpenClaw's context-aware review of the artifact, metadata, and declared behavior.",
|
||||
openclaw: "ClawHub's context-aware review of the artifact, metadata, and declared behavior.",
|
||||
"static-analysis": "Deterministic local checks for risky code patterns and metadata mismatches.",
|
||||
};
|
||||
|
||||
function ScannerIcon({ scanner, className }: { scanner: ScannerSlug; className?: string }) {
|
||||
if (scanner === "virustotal") return <VirusTotalIcon className={className} />;
|
||||
if (scanner === "openclaw") return <OpenClawIcon className={className} />;
|
||||
return <AlertTriangle className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) return "Not checked yet";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
@@ -109,81 +96,6 @@ function getCheckedAt(props: SecurityScannerPageProps) {
|
||||
return props.staticScan?.checkedAt ?? null;
|
||||
}
|
||||
|
||||
function RescanPanel({ entity }: { entity: EntityRef }) {
|
||||
const { me, isAuthenticated } = useAuthStatus();
|
||||
const publisherMemberships = useQuery(api.publishers.listMine, me ? {} : "skip") as
|
||||
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
|
||||
| undefined;
|
||||
const publisherIds = new Set((publisherMemberships ?? []).map((entry) => entry.publisher._id));
|
||||
const isOwner =
|
||||
Boolean(me && entity.ownerUserId && me._id === entity.ownerUserId) ||
|
||||
Boolean(entity.ownerPublisherId && publisherIds.has(entity.ownerPublisherId)) ||
|
||||
isModerator(me);
|
||||
|
||||
if (!isAuthenticated || !isOwner) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rescan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
Owners can request a fresh scan for the latest {entity.kind === "skill" ? "version" : "release"}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Owner Rescan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3">
|
||||
<div className="text-sm text-[color:var(--ink)]">
|
||||
Rescan controls will submit against the latest {entity.kind === "skill" ? "version" : "release"}.
|
||||
</div>
|
||||
<div className="text-xs text-[color:var(--ink-soft)]">
|
||||
Request counts are capped by the backend per published artifact. This branch does not expose the
|
||||
rescan mutation yet, so the action is shown disabled instead of pretending it can schedule work.
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" disabled className="w-full sm:w-fit">
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Request rescan
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScannerTabs({ entity, active }: { entity: EntityRef; active: ScannerSlug }) {
|
||||
const scanners: ScannerSlug[] = ["virustotal", "openclaw", "static-analysis"];
|
||||
return (
|
||||
<nav className="flex flex-wrap gap-2" aria-label="Security scanners">
|
||||
{scanners.map((scanner) => {
|
||||
const isActive = scanner === active;
|
||||
return (
|
||||
<a
|
||||
key={scanner}
|
||||
href={`${entity.securityBasePath}/${scanner}`}
|
||||
className={`inline-flex min-h-[34px] items-center gap-2 rounded-[var(--r-btn)] border px-3 py-1.5 text-xs font-semibold no-underline ${
|
||||
isActive
|
||||
? "border-[color:var(--accent)] bg-[color:var(--active-bg)] text-[color:var(--ink)]"
|
||||
: "border-[color:var(--line)] text-[color:var(--ink-soft)] hover:bg-[color:var(--surface-muted)]"
|
||||
}`}
|
||||
>
|
||||
<ScannerIcon scanner={scanner} className="h-3.5 w-3.5" />
|
||||
{SCANNER_LABELS[scanner]}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecurityScannerPage(props: SecurityScannerPageProps) {
|
||||
const label = SCANNER_LABELS[props.scanner];
|
||||
const status = getScannerStatus(props);
|
||||
@@ -203,7 +115,7 @@ export function SecurityScannerPage(props: SecurityScannerPageProps) {
|
||||
Back to {props.entity.kind}
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge>{props.entity.kind === "skill" ? "Skill" : "Plugin"}</Badge>
|
||||
@@ -216,18 +128,14 @@ export function SecurityScannerPage(props: SecurityScannerPageProps) {
|
||||
{props.entity.title} · {SCANNER_SUMMARIES[props.scanner]}
|
||||
</p>
|
||||
</div>
|
||||
<ScannerTabs entity={props.entity} active={props.scanner} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="security-scanner-layout">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ScannerIcon scanner={props.scanner} className="h-5 w-5" />
|
||||
Scanner verdict
|
||||
</CardTitle>
|
||||
<CardTitle>Scanner verdict</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -278,7 +186,7 @@ export function SecurityScannerPage(props: SecurityScannerPageProps) {
|
||||
<DetailRow label="Verdict">{props.llmAnalysis?.verdict ?? props.llmAnalysis?.status ?? "Pending"}</DetailRow>
|
||||
<DetailRow label="Confidence">{props.llmAnalysis?.confidence ?? "Not reported"}</DetailRow>
|
||||
<DetailRow label="Model">{props.llmAnalysis?.model ?? "Not reported"}</DetailRow>
|
||||
<DetailRow label="Summary">{props.llmAnalysis?.summary ?? "No OpenClaw analysis has been recorded yet."}</DetailRow>
|
||||
<DetailRow label="Summary">{props.llmAnalysis?.summary ?? "No ClawScan analysis has been recorded yet."}</DetailRow>
|
||||
<DetailRow label="Guidance">{props.llmAnalysis?.guidance ?? null}</DetailRow>
|
||||
<DetailRow label="Findings">
|
||||
{props.llmAnalysis?.findings ? (
|
||||
@@ -375,7 +283,6 @@ export function SecurityScannerPage(props: SecurityScannerPageProps) {
|
||||
</div>
|
||||
|
||||
<aside className="flex min-w-0 flex-col gap-5">
|
||||
<RescanPanel entity={props.entity} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Artifact</CardTitle>
|
||||
|
||||
@@ -42,8 +42,8 @@ describe("SignInButton", () => {
|
||||
it("starts GitHub sign-in with the current relative URL by default", async () => {
|
||||
signInMock.mockResolvedValue({ signingIn: true });
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
render(<SignInButton />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInMock).toHaveBeenCalledWith("github", {
|
||||
@@ -57,8 +57,8 @@ describe("SignInButton", () => {
|
||||
it("surfaces a generic error when sign-in resolves without redirecting", async () => {
|
||||
signInMock.mockResolvedValue({ signingIn: false });
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
render(<SignInButton />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again.");
|
||||
@@ -70,8 +70,8 @@ describe("SignInButton", () => {
|
||||
signInMock.mockRejectedValue(failure);
|
||||
getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable");
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
render(<SignInButton />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -12,14 +12,16 @@ type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
|
||||
|
||||
export function SignInButton({
|
||||
redirectTo,
|
||||
children = "Sign in with GitHub",
|
||||
children = "Sign In",
|
||||
...props
|
||||
}: SignInButtonProps) {
|
||||
const { signIn } = useAuthActions();
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
clearAuthError();
|
||||
const next = redirectTo ?? getCurrentRelativeUrl();
|
||||
@@ -35,7 +37,6 @@ export function SignInButton({
|
||||
);
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getUserFacingConvexError } from "../lib/convexError";
|
||||
import { canManageSkill, isModerator } from "../lib/roles";
|
||||
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Card } from "./ui/card";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
import { DetailBody, DetailPageShell } from "./DetailPageShell";
|
||||
import { DetailSecuritySummary } from "./DetailSecuritySummary";
|
||||
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
|
||||
import { SkillCommentsPanel } from "./SkillCommentsPanel";
|
||||
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
|
||||
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
|
||||
import {
|
||||
buildSkillHref,
|
||||
formatConfigSnippet,
|
||||
@@ -22,16 +26,22 @@ import {
|
||||
import { SkillHeader } from "./SkillHeader";
|
||||
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
|
||||
import { SkillReportDialog } from "./SkillReportDialog";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
|
||||
type SkillDetailPageProps = {
|
||||
slug: string;
|
||||
canonicalOwner?: string;
|
||||
redirectToCanonical?: boolean;
|
||||
initialData?: SkillPageInitialData | null;
|
||||
mode?: "detail" | "settings";
|
||||
};
|
||||
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
const SHOW_SKILL_COMMENTS = false;
|
||||
|
||||
function formatReportError(error: unknown) {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = (error as { data?: unknown }).data;
|
||||
@@ -65,6 +75,7 @@ export function SkillDetailPage({
|
||||
canonicalOwner,
|
||||
redirectToCanonical,
|
||||
initialData,
|
||||
mode = "detail",
|
||||
}: SkillDetailPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, me } = useAuthStatus();
|
||||
@@ -83,6 +94,7 @@ export function SkillDetailPage({
|
||||
const reportSkill = useMutation(api.skills.report);
|
||||
const updateTags = useMutation(api.skills.updateTags);
|
||||
const deleteTags = useMutation(api.skills.deleteTags);
|
||||
const requestRescan = useMutation(api.skills.requestRescan);
|
||||
const getReadme = useAction(api.skills.getReadme);
|
||||
const myPublishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
|
||||
@@ -145,6 +157,11 @@ export function SkillDetailPage({
|
||||
: { ownerUserId: skill.ownerUserId, limit: 100 }
|
||||
: "skip",
|
||||
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
|
||||
const canViewOwnerRescanState = isOwner || me?.role === "admin";
|
||||
const rescanState = useQuery(
|
||||
api.skills.getRescanState,
|
||||
canViewOwnerRescanState && skill ? { skillId: skill._id } : "skip",
|
||||
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
|
||||
|
||||
const ownerHandle = owner?.handle ?? null;
|
||||
const ownerParam = ownerHandle?.trim().toLowerCase() || (owner?._id ? String(owner._id) : null);
|
||||
@@ -237,7 +254,10 @@ export function SkillDetailPage({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (latestVersion && !(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))) {
|
||||
if (
|
||||
latestVersion &&
|
||||
!(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))
|
||||
) {
|
||||
setReadme(null);
|
||||
setReadmeError(null);
|
||||
setLoadedReadmeVersionId(latestVersion._id);
|
||||
@@ -325,19 +345,36 @@ export function SkillDetailPage({
|
||||
}
|
||||
};
|
||||
|
||||
const submitRescanRequest = async () => {
|
||||
if (!skill) return;
|
||||
try {
|
||||
await requestRescan({ skillId: skill._id });
|
||||
toast.success("Rescan requested.", {
|
||||
action: {
|
||||
label: "Dashboard",
|
||||
onClick: () => {
|
||||
window.location.href = "/dashboard";
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingSkill || wantsCanonicalRedirect) {
|
||||
return (
|
||||
<main className="section">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading skill…</div>
|
||||
</Card>
|
||||
<main className="section detail-page-section" aria-busy="true">
|
||||
<div role="status" aria-label="Loading skill details">
|
||||
<SkillDetailSkeleton />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === null || !skill) {
|
||||
return (
|
||||
<main className="section">
|
||||
<main className="section detail-page-section">
|
||||
<Card>Skill not found.</Card>
|
||||
</main>
|
||||
);
|
||||
@@ -353,10 +390,25 @@ export function SkillDetailPage({
|
||||
latestTagVersionId === null
|
||||
? []
|
||||
: tagEntries.filter(([, versionId]) => versionId !== latestTagVersionId);
|
||||
const securitySummary = latestVersion ? (
|
||||
<DetailSecuritySummary
|
||||
scannerBasePath={`/${encodeURIComponent(
|
||||
ownerParam ?? ownerHandle ?? "unknown",
|
||||
)}/${encodeURIComponent(skill.slug)}/security`}
|
||||
sha256hash={latestVersion.sha256hash ?? null}
|
||||
vtAnalysis={latestVersion.vtAnalysis ?? null}
|
||||
llmAnalysis={latestVersion.llmAnalysis ?? null}
|
||||
staticScan={latestVersion.staticScan ?? null}
|
||||
rescanState={rescanState ?? null}
|
||||
onRequestRescan={canViewOwnerRescanState ? submitRescanRequest : null}
|
||||
/>
|
||||
) : null;
|
||||
const detailPath = `/${encodeURIComponent(ownerParam ?? ownerHandle ?? "unknown")}/${encodeURIComponent(skill.slug)}`;
|
||||
const settingsHref = canManage ? `${detailPath}/settings` : null;
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<main className="section detail-page-section">
|
||||
<DetailPageShell>
|
||||
<SkillHeader
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
@@ -384,102 +436,183 @@ export function SkillDetailPage({
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
tagEntries={currentTagEntries}
|
||||
historicalTagEntries={historicalTagEntries}
|
||||
versionById={versionById}
|
||||
tagName={tagName}
|
||||
onTagNameChange={setTagName}
|
||||
tagVersionId={tagVersionId}
|
||||
onTagVersionChange={setTagVersionId}
|
||||
onTagSubmit={submitTag}
|
||||
onTagDelete={deleteTag}
|
||||
tagVersions={versions ?? []}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
/>
|
||||
sidebarContent={securitySummary}
|
||||
settingsHref={settingsHref}
|
||||
>
|
||||
{mode === "detail" ? (
|
||||
<>
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : null}
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">{configExample}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillMetadataSidebar
|
||||
skill={skill}
|
||||
latestVersion={latestVersion}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
tagEntries={currentTagEntries}
|
||||
isMalwareBlocked={modInfo?.isMalwareBlocked}
|
||||
isRemoved={modInfo?.isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
/>
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
skill={skill as Doc<"skills">}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
|
||||
<div className="detail-content-full">
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{nixSnippet}
|
||||
</pre>
|
||||
</Card>
|
||||
<Card className="skill-tag-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Version tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="skill-tag-row">
|
||||
{currentTagEntries.length === 0 ? (
|
||||
<span className="section-subtitle m-0">No tags yet.</span>
|
||||
) : (
|
||||
currentTagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{canManage && tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-delete"
|
||||
onClick={() => deleteTag(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && historicalTagEntries.length > 0 ? (
|
||||
<div className="skill-tag-history">
|
||||
<div className="skill-tag-history-label">Historical tags</div>
|
||||
<div className="skill-tag-row">
|
||||
{historicalTagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-delete"
|
||||
onClick={() => deleteTag(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitTag();
|
||||
}}
|
||||
className="tag-form"
|
||||
>
|
||||
<input
|
||||
aria-label="Tag name"
|
||||
className="search-input"
|
||||
name="tagName"
|
||||
value={tagName}
|
||||
onChange={(event) => setTagName(event.target.value)}
|
||||
placeholder="latest..."
|
||||
/>
|
||||
<select
|
||||
aria-label="Tag version"
|
||||
className="search-input"
|
||||
name="tagVersion"
|
||||
value={tagVersionId ?? ""}
|
||||
onChange={(event) =>
|
||||
setTagVersionId(event.target.value as Id<"skillVersions">)
|
||||
}
|
||||
>
|
||||
{(versions ?? []).map((version) => (
|
||||
<option key={version._id} value={version._id}>
|
||||
v{version.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit">Update Tag</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{SHOW_SKILL_COMMENTS ? (
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Comments</h2>
|
||||
<p className="section-subtitle mt-3 mb-0">Loading comments...</p>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
skillId={skill._id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
me={me ?? null}
|
||||
/>
|
||||
</ClientOnly>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</SkillHeader>
|
||||
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{configExample}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
skill={skill as Doc<"skills">}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
|
||||
<ClientOnly
|
||||
fallback={
|
||||
{mode === "settings" ? (
|
||||
<DetailBody>
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Comments
|
||||
</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Settings unavailable</h2>
|
||||
<p className="section-subtitle mt-3 mb-0">
|
||||
Loading comments...
|
||||
Only the skill owner can manage these settings.
|
||||
</p>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
skillId={skill._id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
me={me ?? null}
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DetailBody>
|
||||
) : null}
|
||||
</DetailPageShell>
|
||||
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
|
||||
+154
-265
@@ -1,14 +1,16 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Package } from "lucide-react";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
|
||||
import { Calendar, Download, History, Package, Scale, Settings, Star } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
|
||||
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { DetailHero } from "./DetailPageShell";
|
||||
import { SkillInstallCard } from "./SkillInstallCard";
|
||||
import { SkillInstallSurface } from "./SkillInstallSurface";
|
||||
import { SkillCommandLineCard } from "./SkillInstallSurface";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
@@ -63,18 +65,11 @@ type SkillHeaderProps = {
|
||||
hasPluginBundle: boolean;
|
||||
configRequirements: ClawdisSkillMetadata["config"] | undefined;
|
||||
cliHelp: string | undefined;
|
||||
tagEntries: Array<[string, Id<"skillVersions">]>;
|
||||
historicalTagEntries: Array<[string, Id<"skillVersions">]>;
|
||||
versionById: Map<Id<"skillVersions">, Doc<"skillVersions">>;
|
||||
tagName: string;
|
||||
onTagNameChange: (value: string) => void;
|
||||
tagVersionId: Id<"skillVersions"> | "";
|
||||
onTagVersionChange: (value: Id<"skillVersions"> | "") => void;
|
||||
onTagSubmit: () => void;
|
||||
onTagDelete: (tag: string) => void;
|
||||
tagVersions: Doc<"skillVersions">[];
|
||||
clawdis: ClawdisSkillMetadata | undefined;
|
||||
osLabels: string[];
|
||||
sidebarContent?: ReactNode;
|
||||
settingsHref?: string | null;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function SkillHeader({
|
||||
@@ -104,33 +99,14 @@ export function SkillHeader({
|
||||
hasPluginBundle,
|
||||
configRequirements,
|
||||
cliHelp,
|
||||
tagEntries,
|
||||
historicalTagEntries,
|
||||
versionById,
|
||||
tagName,
|
||||
onTagNameChange,
|
||||
tagVersionId,
|
||||
onTagVersionChange,
|
||||
onTagSubmit,
|
||||
onTagDelete,
|
||||
tagVersions,
|
||||
clawdis,
|
||||
osLabels,
|
||||
sidebarContent,
|
||||
settingsHref,
|
||||
children,
|
||||
}: SkillHeaderProps) {
|
||||
const formattedStats = formatSkillStatsTriplet(skill.stats);
|
||||
const suppressScanResults =
|
||||
!isStaff &&
|
||||
Boolean(modInfo?.overrideActive) &&
|
||||
!modInfo?.isMalwareBlocked &&
|
||||
!modInfo?.isSuspicious;
|
||||
const overrideScanMessage = suppressScanResults
|
||||
? "Security findings were reviewed by staff and cleared for public use."
|
||||
: null;
|
||||
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
|
||||
const ownerSegment = ownerHandle?.trim() || (installOwnerId ? String(installOwnerId) : null);
|
||||
const scannerBasePath = ownerSegment
|
||||
? `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}/security`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -145,16 +121,6 @@ export function SkillHeader({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isMalwareBlocked ? (
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill blocked — malicious content detected</strong>
|
||||
<p>
|
||||
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
|
||||
scan results below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isSuspicious ? (
|
||||
<div className="pending-banner pending-banner-warning">
|
||||
<div className="pending-banner-content">
|
||||
@@ -194,146 +160,161 @@ export function SkillHeader({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card skill-hero">
|
||||
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
|
||||
<div className="skill-hero-header">
|
||||
<div className="skill-hero-title">
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="section-title m-0">
|
||||
{skill.displayName}
|
||||
</h1>
|
||||
{latestVersion?.version ? (
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
</div>
|
||||
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
|
||||
|
||||
{isStaff && staffModerationNote ? (
|
||||
<div className="skill-hero-note">{staffModerationNote}</div>
|
||||
) : null}
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="skill-hero-inline-meta">
|
||||
<div className="skill-hero-stats-row">
|
||||
<span className="stat">⭐ {formattedStats.stars}</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">{formattedStats.installsAllTime} all-time</span>
|
||||
</div>
|
||||
<div className="skill-hero-meta-row">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
</>
|
||||
<DetailHero
|
||||
topClassName={hasPluginBundle ? "has-plugin" : undefined}
|
||||
main={
|
||||
<>
|
||||
<div className="skill-hero-title">
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="skill-page-title">{skill.displayName}</h1>
|
||||
{latestVersion?.version ? (
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
{isStaff || settingsHref ? (
|
||||
<div className="skill-title-actions">
|
||||
{isStaff ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{settingsHref ? (
|
||||
<Button asChild variant="outline" size="sm" className="skill-settings-link">
|
||||
<a href={settingsHref}>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
Settings
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
|
||||
|
||||
{isStaff && staffModerationNote ? (
|
||||
<div className="skill-hero-note">{staffModerationNote}</div>
|
||||
) : null}
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="skill-hero-inline-meta">
|
||||
<div className="skill-hero-stats-row">
|
||||
<span className="stat">
|
||||
<Star size={14} aria-hidden="true" /> {formattedStats.stars}
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<Download size={14} aria-hidden="true" /> {formattedStats.downloads}
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<Package size={14} aria-hidden="true" /> {skill.stats.versions ?? 0} versions
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<History size={14} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<History size={14} aria-hidden="true" /> {formattedStats.installsAllTime}{" "}
|
||||
all-time
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<Calendar size={14} aria-hidden="true" /> Updated {timeAgo(skill.updatedAt)}
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<Scale size={14} aria-hidden="true" /> {PLATFORM_SKILL_LICENSE}
|
||||
</span>
|
||||
</div>
|
||||
<div className="skill-hero-meta-row">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-hero-badges">
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
|
||||
{staffVisibilityTag}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-hero-badges">
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
|
||||
{staffVisibilityTag}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-hero-sidebar">
|
||||
<div className="skill-actions">
|
||||
{isAuthenticated ? (
|
||||
</>
|
||||
}
|
||||
sidebar={
|
||||
<>
|
||||
{isAuthenticated ? (
|
||||
<div className="skill-actions">
|
||||
<button
|
||||
className={`star-toggle${isStarred ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? "Unstar skill" : "Star skill"}
|
||||
>
|
||||
<span aria-hidden="true">★</span>
|
||||
<Star size={16} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{isAuthenticated ? (
|
||||
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
|
||||
Report
|
||||
</Button>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Button asChild size="sm">
|
||||
<Link to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillInstallSurface
|
||||
slug={skill.slug}
|
||||
displayName={skill.displayName}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={installOwnerId}
|
||||
clawdis={clawdis}
|
||||
/>
|
||||
|
||||
{/* Security scan — full width below the header columns */}
|
||||
{suppressScanResults ? (
|
||||
<div className="skill-hero-note">{overrideScanMessage}</div>
|
||||
) : latestVersion?.sha256hash ||
|
||||
latestVersion?.llmAnalysis ||
|
||||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
|
||||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
|
||||
<div className="skill-hero-scan-row">
|
||||
<SecurityScanResults
|
||||
sha256hash={latestVersion?.sha256hash}
|
||||
vtAnalysis={latestVersion?.vtAnalysis}
|
||||
llmAnalysis={latestVersion?.llmAnalysis as LlmAnalysis | undefined}
|
||||
staticFindings={latestVersion?.staticScan?.findings}
|
||||
capabilityTags={latestVersion?.capabilityTags}
|
||||
scannerBasePath={scannerBasePath}
|
||||
</div>
|
||||
) : null}
|
||||
{sidebarContent}
|
||||
<SkillCommandLineCard
|
||||
slug={skill.slug}
|
||||
displayName={skill.displayName}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={installOwnerId}
|
||||
clawdis={clawdis}
|
||||
/>
|
||||
<p className="scan-disclaimer">
|
||||
Like a lobster shell, security has layers — review code before you run it.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
{hasPluginBundle ? (
|
||||
<div className="skill-panel bundle-card">
|
||||
<div className="bundle-header">
|
||||
@@ -373,99 +354,7 @@ export function SkillHeader({
|
||||
</div>
|
||||
) : null}
|
||||
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
|
||||
</div>
|
||||
|
||||
<div className="skill-tag-row">
|
||||
{tagEntries.length === 0 ? (
|
||||
<span className="section-subtitle m-0">
|
||||
No tags yet.
|
||||
</span>
|
||||
) : (
|
||||
tagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{canManage && tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-delete"
|
||||
onClick={() => onTagDelete(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && historicalTagEntries.length > 0 ? (
|
||||
<div className="skill-tag-history">
|
||||
<div className="skill-tag-history-label">Historical tags</div>
|
||||
<div className="skill-tag-row">
|
||||
{historicalTagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-delete"
|
||||
onClick={() => onTagDelete(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onTagSubmit();
|
||||
}}
|
||||
className="tag-form"
|
||||
>
|
||||
<input
|
||||
aria-label="Tag name"
|
||||
className="search-input"
|
||||
name="tagName"
|
||||
value={tagName}
|
||||
onChange={(event) => onTagNameChange(event.target.value)}
|
||||
placeholder="latest…"
|
||||
/>
|
||||
<select
|
||||
aria-label="Tag version"
|
||||
className="search-input"
|
||||
name="tagVersion"
|
||||
value={tagVersionId ?? ""}
|
||||
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
{tagVersions.map((version) => (
|
||||
<option key={version._id} value={version._id}>
|
||||
v{version.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit">
|
||||
Update Tag
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</DetailHero>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SkillInstallSurface } from "./SkillInstallSurface";
|
||||
import { SkillCommandLineCard, SkillInstallSurface } from "./SkillInstallSurface";
|
||||
|
||||
const writeTextMock = vi.fn();
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("SkillInstallSurface", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Install with OpenClaw" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
|
||||
expect(screen.queryByRole("heading", { name: "CLI Commands" })).toBeNull();
|
||||
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
|
||||
expect(screen.getAllByText("Install & Setup").length).toBeGreaterThan(0);
|
||||
|
||||
@@ -72,9 +72,9 @@ describe("SkillInstallSurface", () => {
|
||||
expect(screen.getAllByText("Install Only").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("switches the ClawHub command and copies the visible CLI command", async () => {
|
||||
it("defaults to CLI install and can copy the compact prompt tab", async () => {
|
||||
render(
|
||||
<SkillInstallSurface
|
||||
<SkillCommandLineCard
|
||||
slug="weather"
|
||||
displayName="Weather"
|
||||
ownerHandle="steipete"
|
||||
@@ -82,13 +82,32 @@ describe("SkillInstallSurface", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use pnpm for ClawHub install command" }));
|
||||
expect(screen.getByText("pnpm dlx clawhub@latest install weather")).toBeTruthy();
|
||||
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
|
||||
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy ClawHub CLI command" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw CLI command" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith("pnpm dlx clawhub@latest install weather");
|
||||
expect(writeTextMock).toHaveBeenCalledWith("openclaw skills install weather");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Prompt" }));
|
||||
|
||||
expect(screen.getByText(/Install the skill "Weather"/i)).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw prompt" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("After install, inspect the skill metadata"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,8 @@ import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { copyText, InstallCopyButton } from "./InstallCopyButton";
|
||||
import {
|
||||
buildSkillInstallTarget,
|
||||
formatClawHubInstallCommand,
|
||||
formatOpenClawInstallCommand,
|
||||
formatOpenClawPrompt,
|
||||
type SkillPackageManager,
|
||||
type SkillPromptMode,
|
||||
} from "./skillDetailUtils";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -19,8 +17,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
|
||||
const PACKAGE_MANAGERS: SkillPackageManager[] = ["npm", "pnpm", "bun"];
|
||||
|
||||
const PROMPT_OPTIONS: Array<{
|
||||
description: string;
|
||||
label: string;
|
||||
@@ -56,7 +52,6 @@ export function SkillInstallSurface({
|
||||
clawdis,
|
||||
}: SkillInstallSurfaceProps) {
|
||||
const headingId = useId();
|
||||
const [packageManager, setPackageManager] = useState<SkillPackageManager>("npm");
|
||||
const [promptMode, setPromptMode] = useState<SkillPromptMode>("install-and-setup");
|
||||
const [promptCopyState, setPromptCopyState] = useState<PromptCopyState>("idle");
|
||||
const promptResetTimeoutRef = useRef<number | null>(null);
|
||||
@@ -81,10 +76,9 @@ export function SkillInstallSurface({
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const selectedPrompt = PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
|
||||
const selectedPrompt =
|
||||
PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
|
||||
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
|
||||
const openClawCommand = formatOpenClawInstallCommand(slug);
|
||||
const clawHubCommand = formatClawHubInstallCommand(slug, packageManager);
|
||||
const promptPreview = formatOpenClawPrompt({
|
||||
mode: promptMode,
|
||||
skillName: displayName,
|
||||
@@ -130,109 +124,120 @@ export function SkillInstallSurface({
|
||||
Install
|
||||
</h2>
|
||||
|
||||
<div className="skill-install-grid">
|
||||
<article className="skill-install-panel">
|
||||
<div className="skill-install-panel-header">
|
||||
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
|
||||
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
|
||||
<p className="skill-install-panel-copy">
|
||||
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
|
||||
for <code translate="no">{installTarget}</code>.
|
||||
</p>
|
||||
</div>
|
||||
<article className="skill-install-panel">
|
||||
<div className="skill-install-panel-header">
|
||||
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
|
||||
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
|
||||
<p className="skill-install-panel-copy">
|
||||
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
|
||||
for <code translate="no">{installTarget}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-actions">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" className="skill-install-prompt-trigger">
|
||||
<span>Copy Prompt</span>
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="skill-install-menu">
|
||||
{PROMPT_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
|
||||
<div className="skill-install-menu-copy">
|
||||
<span className="skill-install-menu-label">{option.label}</span>
|
||||
<span className="skill-install-menu-description">{option.description}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="skill-install-copy-feedback" aria-live="polite">
|
||||
{promptFeedback}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-preview-meta">
|
||||
<span className="skill-install-preview-label">Prompt Preview</span>
|
||||
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
|
||||
</div>
|
||||
|
||||
<pre className="skill-install-prompt-preview">
|
||||
<code translate="no">{promptPreview}</code>
|
||||
</pre>
|
||||
</article>
|
||||
|
||||
<article className="skill-install-panel">
|
||||
<div className="skill-install-panel-header">
|
||||
<p className="skill-install-kicker">Command Line</p>
|
||||
<h3 className="skill-install-panel-title">CLI Commands</h3>
|
||||
<p className="skill-install-panel-copy">
|
||||
Use the direct CLI path if you want to install manually and keep every step visible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-command-card">
|
||||
<div className="skill-install-command-header">
|
||||
<div className="skill-install-command-copy">
|
||||
<p className="skill-install-command-label">OpenClaw CLI</p>
|
||||
<p className="skill-install-command-caption">Bare skill slug</p>
|
||||
</div>
|
||||
<InstallCopyButton
|
||||
text={openClawCommand}
|
||||
ariaLabel="Copy OpenClaw CLI command"
|
||||
/>
|
||||
</div>
|
||||
<pre className="skill-install-command">
|
||||
<code translate="no">{openClawCommand}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-command-card">
|
||||
<div className="skill-install-command-header">
|
||||
<div className="skill-install-command-copy">
|
||||
<p className="skill-install-command-label">ClawHub CLI</p>
|
||||
<p className="skill-install-command-caption">Package manager switcher</p>
|
||||
</div>
|
||||
<InstallCopyButton
|
||||
text={clawHubCommand}
|
||||
ariaLabel="Copy ClawHub CLI command"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="install-switcher-toggle" aria-label="ClawHub install command">
|
||||
{PACKAGE_MANAGERS.map((entry) => (
|
||||
<button
|
||||
key={entry}
|
||||
type="button"
|
||||
aria-label={`Use ${entry} for ClawHub install command`}
|
||||
aria-pressed={packageManager === entry}
|
||||
className={`install-switcher-pill${packageManager === entry ? " is-active" : ""}`}
|
||||
onClick={() => setPackageManager(entry)}
|
||||
>
|
||||
{entry}
|
||||
</button>
|
||||
<div className="skill-install-actions">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" className="skill-install-prompt-trigger">
|
||||
<span>Copy Prompt</span>
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="skill-install-menu">
|
||||
{PROMPT_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
|
||||
<div className="skill-install-menu-copy">
|
||||
<span className="skill-install-menu-label">{option.label}</span>
|
||||
<span className="skill-install-menu-description">{option.description}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="skill-install-copy-feedback" aria-live="polite">
|
||||
{promptFeedback}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<pre className="skill-install-command">
|
||||
<code translate="no">{clawHubCommand}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div className="skill-install-preview-meta">
|
||||
<span className="skill-install-preview-label">Prompt Preview</span>
|
||||
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
|
||||
</div>
|
||||
|
||||
<pre className="skill-install-prompt-preview">
|
||||
<code translate="no">{promptPreview}</code>
|
||||
</pre>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillCommandLineCard({
|
||||
slug,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
}: SkillInstallSurfaceProps) {
|
||||
const [activeInstallTab, setActiveInstallTab] = useState<"cli" | "prompt">("cli");
|
||||
const openClawCommand = formatOpenClawInstallCommand(slug);
|
||||
const promptPreview = formatOpenClawPrompt({
|
||||
mode: "install-and-setup",
|
||||
skillName: displayName,
|
||||
slug,
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
});
|
||||
|
||||
return (
|
||||
<article className="skill-install-command-card">
|
||||
<div className="skill-install-command-header">
|
||||
<h3 className="skill-install-panel-title">Install</h3>
|
||||
<div className="install-switcher-toggle" role="tablist" aria-label="Install option">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeInstallTab === "cli"}
|
||||
className={`install-switcher-pill${activeInstallTab === "cli" ? " is-active" : ""}`}
|
||||
onClick={() => setActiveInstallTab("cli")}
|
||||
>
|
||||
CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeInstallTab === "prompt"}
|
||||
className={`install-switcher-pill${
|
||||
activeInstallTab === "prompt" ? " is-active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveInstallTab("prompt")}
|
||||
>
|
||||
Prompt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-command-wrap">
|
||||
<pre
|
||||
className={`skill-install-command${
|
||||
activeInstallTab === "prompt" ? " skill-install-prompt-compact" : ""
|
||||
}`}
|
||||
>
|
||||
<code translate="no">
|
||||
{activeInstallTab === "prompt" ? promptPreview : openClawCommand}
|
||||
</code>
|
||||
</pre>
|
||||
<InstallCopyButton
|
||||
text={activeInstallTab === "prompt" ? promptPreview : openClawCommand}
|
||||
ariaLabel={
|
||||
activeInstallTab === "prompt"
|
||||
? "Copy OpenClaw prompt"
|
||||
: "Copy OpenClaw CLI command"
|
||||
}
|
||||
className="skill-install-command-inline-button"
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
|
||||
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
type SkillMetadataSidebarProps = {
|
||||
skill: PublicSkill;
|
||||
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
clawdis?: ClawdisSkillMetadata;
|
||||
osLabels: string[];
|
||||
tagEntries: Array<[string, Id<"skillVersions">]>;
|
||||
isMalwareBlocked?: boolean;
|
||||
isRemoved?: boolean;
|
||||
nixPlugin?: string;
|
||||
};
|
||||
|
||||
export function SkillMetadataSidebar({
|
||||
skill,
|
||||
latestVersion,
|
||||
owner,
|
||||
ownerHandle,
|
||||
clawdis: _clawdis,
|
||||
osLabels,
|
||||
tagEntries,
|
||||
isMalwareBlocked,
|
||||
isRemoved,
|
||||
nixPlugin,
|
||||
}: SkillMetadataSidebarProps) {
|
||||
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
|
||||
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
|
||||
|
||||
return (
|
||||
<div className="detail-meta-bar">
|
||||
{/* Stats row */}
|
||||
<div className="meta-bar-stats">
|
||||
<div className="meta-stat">
|
||||
<Download size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
|
||||
<span className="meta-stat-label">downloads</span>
|
||||
</div>
|
||||
<div className="meta-stat">
|
||||
<Star size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
|
||||
<span className="meta-stat-label">stars</span>
|
||||
</div>
|
||||
<div className="meta-stat">
|
||||
<Package size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
|
||||
<span className="meta-stat-label">versions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details row */}
|
||||
<div className="meta-bar-details">
|
||||
<div className="meta-detail">
|
||||
<Calendar size={12} aria-hidden="true" />
|
||||
<span>Updated {timeAgo(skill.updatedAt)}</span>
|
||||
</div>
|
||||
{latestVersion?.version ? (
|
||||
<div className="meta-detail">
|
||||
<Tag size={12} aria-hidden="true" />
|
||||
<span>v{latestVersion.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="meta-detail">
|
||||
<Scale size={12} aria-hidden="true" />
|
||||
<span>{PLATFORM_SKILL_LICENSE}</span>
|
||||
</div>
|
||||
{osLabels.length > 0 ? (
|
||||
<div className="meta-detail">
|
||||
<span>{osLabels.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tags and Publisher row */}
|
||||
<div className="meta-bar-footer">
|
||||
<div className="meta-bar-publisher">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="sm"
|
||||
showName
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tagEntries.length > 0 ? (
|
||||
<div className="meta-bar-tags">
|
||||
{tagEntries.map(([tag]) => (
|
||||
<Badge key={tag} variant="compact">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDownload ? (
|
||||
<Button asChild variant="primary" size="sm">
|
||||
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
@@ -77,33 +78,8 @@ export function VirusTotalIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenClawIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-label="OpenClaw"
|
||||
>
|
||||
<title>OpenClaw</title>
|
||||
<path
|
||||
d="M12 2C8.5 2 5.5 4 4 7c-2 4-1 8 2 11 1.5 1.5 3.5 2.5 6 2.5s4.5-1 6-2.5c3-3 4-7 2-11-1.5-3-4.5-5-8-5z"
|
||||
fill="currentColor"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M9 8c1-2 3-3 5-2s3 3 2 5l-3 4-2-1 3-4c.5-1 0-2-1-2.5S11 7 10.5 8L8 12l-2-1 3-4z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M15 8c-1-2-3-3-5-2s-3 3-2 5l3 4 2-1-3-4c-.5-1 0-2 1-2.5S14 7 14.5 8L17 12l2-1-4-3z"
|
||||
fill="currentColor"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export function ClawScanIcon({ className }: { className?: string }) {
|
||||
return <ShieldCheck className={className} aria-label="ClawScan" />;
|
||||
}
|
||||
|
||||
export function getScanStatusInfo(status: string) {
|
||||
@@ -251,7 +227,7 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
|
||||
return {
|
||||
className: "benign",
|
||||
label: "Confirmed safe by external scanners",
|
||||
text: "Static analysis detected API credential-access patterns, but both VirusTotal and OpenClaw confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
|
||||
text: "Static analysis detected API credential-access patterns, but both VirusTotal and ClawScan confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
|
||||
};
|
||||
}
|
||||
const hasCritical = findings.some((f) => f.severity === "critical");
|
||||
@@ -259,13 +235,13 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
|
||||
return {
|
||||
className: "suspicious",
|
||||
label: "Patterns worth reviewing",
|
||||
text: "These patterns may indicate risky behavior. Check the VirusTotal and OpenClaw results above for context-aware analysis before installing.",
|
||||
text: "These patterns may indicate risky behavior. Check the VirusTotal and ClawScan results above for context-aware analysis before installing.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
className: "benign",
|
||||
label: "About static analysis",
|
||||
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and OpenClaw results above for context-aware analysis.",
|
||||
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and ClawScan results above for context-aware analysis.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -384,7 +360,7 @@ export function SecurityScanResults({
|
||||
) : null}
|
||||
{llmStatusInfo ? (
|
||||
<div className="version-scan-badge">
|
||||
<OpenClawIcon className="version-scan-icon version-scan-icon-oc" />
|
||||
<ClawScanIcon className="version-scan-icon version-scan-icon-oc" />
|
||||
<span className={llmStatusInfo.className}>{llmStatusInfo.label}</span>
|
||||
{scannerBasePath ? (
|
||||
<a
|
||||
@@ -456,8 +432,8 @@ export function SecurityScanResults({
|
||||
{llmStatusInfo && llmAnalysis ? (
|
||||
<div className="scan-result-row">
|
||||
<div className="scan-result-scanner">
|
||||
<OpenClawIcon className="scan-result-icon scan-result-icon-oc" />
|
||||
<span className="scan-result-scanner-name">OpenClaw</span>
|
||||
<ClawScanIcon className="scan-result-icon scan-result-icon-oc" />
|
||||
<span className="scan-result-scanner-name">ClawScan</span>
|
||||
</div>
|
||||
<div className={`scan-result-status ${llmStatusInfo.className}`}>
|
||||
{llmStatusInfo.label}
|
||||
|
||||
@@ -2,38 +2,37 @@ import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-page-max px-7 py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-52" />
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-[44px] w-32 rounded-[var(--radius-pill)]" />
|
||||
<Skeleton className="h-[44px] w-36 rounded-[var(--radius-pill)]" />
|
||||
<main className="section">
|
||||
<div className="dashboard-header">
|
||||
<div className="grid gap-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-4 w-72 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div className="dashboard-owner-grid">
|
||||
{["skills", "plugins"].map((section) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5"
|
||||
key={section}
|
||||
className="dashboard-owner-panel flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5"
|
||||
>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-7 w-16" />
|
||||
<div className="dashboard-section-header">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<Skeleton className="h-[34px] w-28 rounded-[var(--r-btn)]" />
|
||||
</div>
|
||||
<div className="dashboard-list">
|
||||
{Array.from({ length: section === "skills" ? 2 : 3 }, (_, index) => (
|
||||
<div key={index} className="dashboard-list-row">
|
||||
<Skeleton className="h-5 w-48 max-w-full" />
|
||||
<Skeleton className="h-5 w-96 max-w-full" />
|
||||
<Skeleton className="h-8 w-24 rounded-[var(--radius-pill)]" />
|
||||
<Skeleton className="h-8 w-8 rounded-[var(--r-btn)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Publisher tabs */}
|
||||
<Skeleton className="mb-6 h-[44px] w-64 rounded-[var(--radius-pill)]" />
|
||||
|
||||
{/* Table skeleton */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-[var(--radius-sm)]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,71 +2,64 @@ import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function SkillDetailSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-page-max px-7 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<Skeleton className="mb-6 h-4 w-48" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_340px]">
|
||||
{/* Main column */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-6 w-16 rounded-[var(--radius-pill)]" />
|
||||
<div className="skill-detail-stack">
|
||||
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Skeleton className="h-9 w-full max-w-[360px]" />
|
||||
<Skeleton className="h-6 w-20 rounded-[var(--r-pill)]" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-full max-w-[680px]" />
|
||||
<Skeleton className="h-5 w-3/4 max-w-[520px]" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-full max-w-lg" />
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* README skeleton */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-6">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="mt-2 h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
|
||||
{/* Tabs skeleton */}
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
|
||||
<Skeleton className="h-[44px] w-28 rounded-[var(--radius-pill)]" />
|
||||
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Install card */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full rounded-[var(--radius-sm)]" />
|
||||
<Skeleton className="h-10 w-full rounded-[var(--radius-pill)]" />
|
||||
</div>
|
||||
{/* Stats card */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="w-full space-y-3 lg:max-w-[360px]">
|
||||
<Skeleton className="h-12 w-full rounded-[var(--r-sm)]" />
|
||||
<Skeleton className="h-12 w-full rounded-[var(--r-pill)]" />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-14" />
|
||||
<Skeleton className="h-14" />
|
||||
<Skeleton className="h-14" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Security card */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-layout">
|
||||
<div className="detail-main">
|
||||
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
|
||||
<Skeleton className="h-10 w-20 rounded-[var(--r-pill)]" />
|
||||
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-11/12" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="mt-5 h-5 w-52" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<Skeleton className="mb-3 h-6 w-28" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,24 +7,25 @@ const buttonVariants = cva(
|
||||
[
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
|
||||
"disabled:pointer-events-none disabled:opacity-60",
|
||||
"cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60",
|
||||
"!no-underline hover:!no-underline",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
|
||||
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--surface-muted)]",
|
||||
primary:
|
||||
"border border-accent bg-accent/10 text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
|
||||
"border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
|
||||
secondary:
|
||||
"border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
|
||||
"border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
|
||||
destructive:
|
||||
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:-translate-y-px hover:not-disabled:bg-active-bg hover:not-disabled:shadow-hover",
|
||||
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
|
||||
ghost:
|
||||
"border border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)]",
|
||||
outline:
|
||||
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)] hover:not-disabled:shadow-hover",
|
||||
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)]",
|
||||
link: "h-auto border border-transparent bg-transparent p-0 text-[color:var(--accent-deep)] underline-offset-4 hover:underline disabled:opacity-60",
|
||||
},
|
||||
size: {
|
||||
|
||||
+1
-60
@@ -53,10 +53,6 @@ const SOULS_SEARCH = {
|
||||
focus: undefined,
|
||||
} as const;
|
||||
|
||||
const USERS_SEARCH = { q: undefined } as const;
|
||||
|
||||
const MANAGEMENT_SEARCH = { skill: undefined } as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primary nav items (desktop tabs row + mobile dropdown top section)
|
||||
// These map to the "content-type" tabs: Skills | Plugins | Souls
|
||||
@@ -100,55 +96,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SECONDARY_NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
label: "Users",
|
||||
to: "/users",
|
||||
search: USERS_SEARCH,
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
},
|
||||
{
|
||||
label: "About",
|
||||
to: "/about",
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
},
|
||||
{
|
||||
label: "Stars",
|
||||
to: "/stars",
|
||||
authRequired: true,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
{
|
||||
label: "Dashboard",
|
||||
to: "/dashboard",
|
||||
authRequired: true,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
{
|
||||
label: "Management",
|
||||
to: "/management",
|
||||
search: MANAGEMENT_SEARCH,
|
||||
authRequired: true,
|
||||
staffOnly: true,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footer sections
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,17 +146,11 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
|
||||
{
|
||||
title: "Community",
|
||||
items: [
|
||||
{ kind: "link", label: "About", to: "/about" },
|
||||
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
|
||||
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Platform",
|
||||
items: [
|
||||
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
|
||||
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Route as PackagesNameRouteImport } from './routes/packages/$name'
|
||||
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
|
||||
import { Route as CliAuthRouteImport } from './routes/cli/auth'
|
||||
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
|
||||
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
|
||||
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
|
||||
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
|
||||
|
||||
@@ -168,6 +169,11 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
|
||||
path: '/$owner/$slug',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => OwnerSlugRoute,
|
||||
} as any)
|
||||
const PluginsNameSecurityScannerRoute =
|
||||
PluginsNameSecurityScannerRouteImport.update({
|
||||
id: '/security/$scanner',
|
||||
@@ -208,6 +214,7 @@ export interface FileRoutesByFullPath {
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
@@ -238,6 +245,7 @@ export interface FileRoutesByTo {
|
||||
'/skills': typeof SkillsIndexRoute
|
||||
'/souls': typeof SoulsIndexRoute
|
||||
'/users': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
@@ -269,6 +277,7 @@ export interface FileRoutesById {
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
@@ -301,6 +310,7 @@ export interface FileRouteTypes {
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -331,6 +341,7 @@ export interface FileRouteTypes {
|
||||
| '/skills'
|
||||
| '/souls'
|
||||
| '/users'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
id:
|
||||
@@ -361,6 +372,7 @@ export interface FileRouteTypes {
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -578,6 +590,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof OwnerSlugRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$owner/$slug/settings': {
|
||||
id: '/$owner/$slug/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/$owner/$slug/settings'
|
||||
preLoaderRoute: typeof OwnerSlugSettingsRouteImport
|
||||
parentRoute: typeof OwnerSlugRoute
|
||||
}
|
||||
'/plugins/$name/security/$scanner': {
|
||||
id: '/plugins/$name/security/$scanner'
|
||||
path: '/security/$scanner'
|
||||
@@ -596,10 +615,12 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
|
||||
interface OwnerSlugRouteChildren {
|
||||
OwnerSlugSettingsRoute: typeof OwnerSlugSettingsRoute
|
||||
OwnerSlugSecurityScannerRoute: typeof OwnerSlugSecurityScannerRoute
|
||||
}
|
||||
|
||||
const OwnerSlugRouteChildren: OwnerSlugRouteChildren = {
|
||||
OwnerSlugSettingsRoute: OwnerSlugSettingsRoute,
|
||||
OwnerSlugSecurityScannerRoute: OwnerSlugSecurityScannerRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createFileRoute, notFound, Outlet, redirect, useRouterState } from "@tanstack/react-router";
|
||||
import {
|
||||
createFileRoute,
|
||||
notFound,
|
||||
Outlet,
|
||||
redirect,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { SkillDetailPage } from "../../components/SkillDetailPage";
|
||||
import { buildSkillMeta } from "../../lib/og";
|
||||
import { fetchSkillPageData } from "../../lib/skillPage";
|
||||
@@ -73,7 +79,10 @@ function OwnerSkill() {
|
||||
const { owner, slug } = Route.useParams();
|
||||
const { initialData } = Route.useLoaderData();
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
if (pathname.includes(`/${encodeURIComponent(slug)}/security/`)) {
|
||||
if (
|
||||
pathname.includes(`/${encodeURIComponent(slug)}/security/`) ||
|
||||
pathname.endsWith(`/${encodeURIComponent(slug)}/settings`)
|
||||
) {
|
||||
return <Outlet />;
|
||||
}
|
||||
return <SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} />;
|
||||
|
||||
@@ -51,7 +51,7 @@ export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
|
||||
scanner === "virustotal"
|
||||
? "VirusTotal"
|
||||
: scanner === "openclaw"
|
||||
? "OpenClaw"
|
||||
? "ClawScan"
|
||||
: "Static analysis";
|
||||
const meta = buildSkillMeta({
|
||||
slug: params.slug,
|
||||
@@ -111,7 +111,6 @@ function SkillSecurityScannerRoute() {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId ?? null,
|
||||
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
|
||||
securityBasePath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}/security`,
|
||||
}}
|
||||
sha256hash={latestVersion.sha256hash ?? null}
|
||||
vtAnalysis={latestVersion.vtAnalysis ?? null}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { SkillDetailPage } from "../../../components/SkillDetailPage";
|
||||
import { buildSkillMeta } from "../../../lib/og";
|
||||
import { fetchSkillPageData } from "../../../lib/skillPage";
|
||||
|
||||
export const Route = createFileRoute("/$owner/$slug/settings")({
|
||||
beforeLoad: ({ params }) => {
|
||||
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
|
||||
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
|
||||
if (!isHandle && !isOwnerId) {
|
||||
throw notFound();
|
||||
}
|
||||
},
|
||||
loader: async ({ params }) => {
|
||||
const data = await fetchSkillPageData(params.slug);
|
||||
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
|
||||
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
|
||||
|
||||
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
|
||||
throw redirect({
|
||||
to: "/$owner/$slug/settings",
|
||||
params: { owner: canonicalOwner, slug: canonicalSlug },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
owner: data?.owner ?? params.owner,
|
||||
displayName: data?.displayName ?? null,
|
||||
summary: data?.summary ?? null,
|
||||
version: data?.version ?? null,
|
||||
initialData: data.initialData,
|
||||
};
|
||||
},
|
||||
head: ({ params, loaderData }) => {
|
||||
const meta = buildSkillMeta({
|
||||
slug: params.slug,
|
||||
owner: loaderData?.owner ?? params.owner,
|
||||
displayName: loaderData?.displayName,
|
||||
summary: loaderData?.summary,
|
||||
version: loaderData?.version ?? null,
|
||||
});
|
||||
return {
|
||||
meta: [
|
||||
{ title: `Settings · ${meta.title}` },
|
||||
{
|
||||
name: "description",
|
||||
content: `Owner settings for ${loaderData?.displayName ?? params.slug}.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
component: SkillSettingsRoute,
|
||||
});
|
||||
|
||||
function SkillSettingsRoute() {
|
||||
const { owner, slug } = Route.useParams();
|
||||
const { initialData } = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} mode="settings" />
|
||||
);
|
||||
}
|
||||
@@ -149,12 +149,7 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
<SignInButton
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</SignInButton>
|
||||
<SignInButton disabled={isLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { Dashboard } from "./dashboard";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useQuery: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useQuery: (...args: unknown[]) => mocks.useQuery(...args),
|
||||
useMutation: (...args: unknown[]) => mocks.useMutation(...args),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mocks.toastSuccess(...args),
|
||||
error: (...args: unknown[]) => mocks.toastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: unknown) => config,
|
||||
Link: ({
|
||||
children,
|
||||
...props
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children: React.ReactNode }) => (
|
||||
<a href="/test" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
type TestSkill = {
|
||||
_id: Id<"skills">;
|
||||
_creationTime: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary: string;
|
||||
ownerUserId: Id<"users">;
|
||||
ownerPublisherId: Id<"publishers">;
|
||||
tags: {};
|
||||
badges: {};
|
||||
stats: {
|
||||
downloads: number;
|
||||
stars: number;
|
||||
versions: number;
|
||||
};
|
||||
moderationVerdict?: "suspicious" | "malicious";
|
||||
moderationFlags?: string[];
|
||||
isSuspicious?: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
latestVersion: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
vtStatus: string | null;
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
};
|
||||
rescanState: {
|
||||
maxRequests: number;
|
||||
requestCount: number;
|
||||
remainingRequests: number;
|
||||
canRequest: boolean;
|
||||
inProgressRequest: null | { _id: string; status: "in_progress"; targetVersion: string };
|
||||
latestRequest: null | { _id: string; status: "completed" | "failed"; targetVersion: string };
|
||||
};
|
||||
};
|
||||
|
||||
type TestPackage = {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "code-plugin";
|
||||
channel: "community";
|
||||
isOfficial: false;
|
||||
runtimeId: string | null;
|
||||
sourceRepo: string | null;
|
||||
summary: string;
|
||||
latestVersion: string;
|
||||
stats: {
|
||||
downloads: number;
|
||||
installs: number;
|
||||
stars: number;
|
||||
versions: number;
|
||||
};
|
||||
verification: null;
|
||||
scanStatus: "suspicious" | "malicious";
|
||||
latestRelease: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
vtStatus: string | null;
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
};
|
||||
rescanState: TestSkill["rescanState"];
|
||||
};
|
||||
|
||||
const me = {
|
||||
_id: "users:local" as Id<"users">,
|
||||
handle: "local",
|
||||
name: "Local Dev",
|
||||
displayName: "Local Dev",
|
||||
};
|
||||
|
||||
const publishers = [
|
||||
{
|
||||
publisher: {
|
||||
_id: "publishers:local" as Id<"publishers">,
|
||||
handle: "local",
|
||||
displayName: "Local",
|
||||
kind: "user" as const,
|
||||
},
|
||||
role: "owner" as const,
|
||||
},
|
||||
];
|
||||
|
||||
function createSkill(overrides?: Partial<TestSkill>): TestSkill {
|
||||
return {
|
||||
_id: "skills:below-cap" as Id<"skills">,
|
||||
_creationTime: 1,
|
||||
slug: "local-flagged-skill",
|
||||
displayName: "Local Flagged Skill",
|
||||
summary: "Flagged skill fixture.",
|
||||
ownerUserId: me._id,
|
||||
ownerPublisherId: publishers[0].publisher._id,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: { downloads: 0, stars: 0, versions: 1 },
|
||||
moderationVerdict: "suspicious",
|
||||
moderationFlags: ["flagged.suspicious"],
|
||||
isSuspicious: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
latestVersion: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
vtStatus: "suspicious",
|
||||
llmStatus: "suspicious",
|
||||
staticScanStatus: "suspicious",
|
||||
},
|
||||
rescanState: {
|
||||
maxRequests: 3,
|
||||
requestCount: 1,
|
||||
remainingRequests: 2,
|
||||
canRequest: true,
|
||||
inProgressRequest: null,
|
||||
latestRequest: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPackage(overrides?: Partial<TestPackage>): TestPackage {
|
||||
return {
|
||||
_id: "packages:at-cap" as Id<"packages">,
|
||||
name: "local-flagged-runtime-plugin",
|
||||
displayName: "Local Flagged Runtime Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
runtimeId: null,
|
||||
sourceRepo: null,
|
||||
summary: "Flagged plugin fixture.",
|
||||
latestVersion: "1.0.0",
|
||||
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
|
||||
verification: null,
|
||||
scanStatus: "malicious",
|
||||
latestRelease: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
vtStatus: "malicious",
|
||||
llmStatus: "malicious",
|
||||
staticScanStatus: "malicious",
|
||||
},
|
||||
rescanState: {
|
||||
maxRequests: 3,
|
||||
requestCount: 3,
|
||||
remainingRequests: 0,
|
||||
canRequest: false,
|
||||
inProgressRequest: null,
|
||||
latestRequest: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function arrangeDashboard({
|
||||
skills = [],
|
||||
packages = [],
|
||||
}: {
|
||||
skills?: TestSkill[];
|
||||
packages?: TestPackage[];
|
||||
}) {
|
||||
let unscopedQueryCount = 0;
|
||||
let scopedQueryCount = 0;
|
||||
mocks.useQuery.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args === undefined) {
|
||||
unscopedQueryCount += 1;
|
||||
return unscopedQueryCount % 2 === 1 ? me : publishers;
|
||||
}
|
||||
scopedQueryCount += 1;
|
||||
return scopedQueryCount % 2 === 1 ? skills : packages;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<Dashboard />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Dashboard minimal rows", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useQuery.mockReset();
|
||||
mocks.useMutation.mockReset();
|
||||
mocks.useMutation.mockReturnValue(vi.fn().mockResolvedValue({}));
|
||||
mocks.toastSuccess.mockReset();
|
||||
mocks.toastError.mockReset();
|
||||
});
|
||||
|
||||
it("renders entry links, summaries, and aggregate statuses only", () => {
|
||||
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
|
||||
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.getByRole("link", { name: "Local Flagged Skill" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Local Flagged Runtime Plugin" })).toBeTruthy();
|
||||
expect(screen.getByText("Flagged skill fixture.")).toBeTruthy();
|
||||
expect(screen.getByText("Flagged plugin fixture.")).toBeTruthy();
|
||||
expect(screen.getByText("Suspicious")).toBeTruthy();
|
||||
expect(screen.getByText("Blocked")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Suspicious status reason" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Blocked status reason" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Open actions for Local Flagged Skill" })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Open actions for Local Flagged Runtime Plugin" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a skeleton while auth state is loading", () => {
|
||||
mocks.useQuery.mockReturnValue(undefined);
|
||||
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.queryByText("Sign in to access your dashboard.")).toBeNull();
|
||||
expect(screen.queryByText("Dashboard")).toBeNull();
|
||||
expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not render row-level actions", () => {
|
||||
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
|
||||
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.queryByRole("button", { name: /request rescan/i })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /new version/i })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /new release/i })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /^view$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render column titles, scanner details, or plugin metadata chips", () => {
|
||||
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
|
||||
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.queryByText("Skill")).toBeNull();
|
||||
expect(screen.queryByText("Plugin")).toBeNull();
|
||||
expect(screen.queryByText("Summary")).toBeNull();
|
||||
expect(screen.queryByText("Status")).toBeNull();
|
||||
expect(screen.queryByText(/^VT:/)).toBeNull();
|
||||
expect(screen.queryByText(/^LLM:/)).toBeNull();
|
||||
expect(screen.queryByText(/^ClawScan:/)).toBeNull();
|
||||
expect(screen.queryByText(/^Static/)).toBeNull();
|
||||
expect(screen.queryByText(/public surfaces warn or suppress it/i)).toBeNull();
|
||||
expect(screen.queryByText(/automated security checks found malicious content/i)).toBeNull();
|
||||
expect(screen.queryByText("Code Plugin")).toBeNull();
|
||||
expect(screen.queryByText("community")).toBeNull();
|
||||
expect(screen.queryByText("2/3 rescans left")).toBeNull();
|
||||
expect(screen.queryByText("Limit reached (3/3)")).toBeNull();
|
||||
});
|
||||
});
|
||||
+269
-351
@@ -1,27 +1,22 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Package,
|
||||
Plug,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { Clock, Info, MoreVertical, Plus, RotateCw, Settings } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { DashboardSkeleton } from "../components/skeletons/DashboardSkeleton";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
|
||||
import { getUserFacingConvexError } from "../lib/convexError";
|
||||
|
||||
const emptyPluginPublishSearch = {
|
||||
ownerHandle: undefined,
|
||||
@@ -65,6 +60,7 @@ type DashboardSkill = Pick<
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
} | null;
|
||||
rescanState?: DashboardRescanState | null;
|
||||
};
|
||||
|
||||
type DashboardPackage = {
|
||||
@@ -96,13 +92,35 @@ type DashboardPackage = {
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
} | null;
|
||||
rescanState?: DashboardRescanState | null;
|
||||
};
|
||||
|
||||
type DashboardRescanState = {
|
||||
maxRequests: number;
|
||||
requestCount: number;
|
||||
remainingRequests: number;
|
||||
canRequest: boolean;
|
||||
inProgressRequest: DashboardRescanRequest | null;
|
||||
latestRequest: DashboardRescanRequest | null;
|
||||
};
|
||||
|
||||
type DashboardRescanRequest = {
|
||||
_id: string;
|
||||
targetKind: "skill" | "plugin";
|
||||
targetVersion: string;
|
||||
requestedByUserId: string;
|
||||
status: "in_progress" | "completed" | "failed";
|
||||
error?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt?: number;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: Dashboard,
|
||||
});
|
||||
|
||||
function Dashboard() {
|
||||
export function Dashboard() {
|
||||
const me = useQuery(api.users.me) as Doc<"users"> | null | undefined;
|
||||
const publishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
@@ -147,7 +165,11 @@ function Dashboard() {
|
||||
}
|
||||
}, [publishers, selectedPublisherId]);
|
||||
|
||||
if (!me) {
|
||||
if (me === undefined) {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
if (me === null) {
|
||||
return (
|
||||
<main className="section">
|
||||
<Card>Sign in to access your dashboard.</Card>
|
||||
@@ -205,48 +227,24 @@ function Dashboard() {
|
||||
<main className="section">
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h1 className="section-title m-0">Publisher Dashboard</h1>
|
||||
<p className="section-subtitle m-0">Manage your published skills and plugins.</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{publishers && publishers.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={selectedPublisherId}
|
||||
onChange={(event) => setSelectedPublisherId(event.target.value)}
|
||||
>
|
||||
{publishers.map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher._id}>
|
||||
@{entry.publisher.handle} · {entry.role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="section-title m-0">Dashboard</h1>
|
||||
<p className="section-subtitle m-0">
|
||||
View your published skills and plugins.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="dashboard-owner-panel">
|
||||
<div className="dashboard-owner-grid">
|
||||
<div className="dashboard-owner-grid">
|
||||
<Card className="dashboard-owner-panel">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="dashboard-collection-title">Publisher Skills</h2>
|
||||
<p className="section-subtitle mt-1.5 mb-0 mx-0">
|
||||
Hidden skill versions remain visible here while checks are pending.
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="dashboard-collection-title">Skills</h2>
|
||||
<Button asChild size="sm" className="dashboard-section-action">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New Skill
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
@@ -254,36 +252,27 @@ function Dashboard() {
|
||||
<strong>No skills yet.</strong> Publish your first skill to share it with the
|
||||
community.
|
||||
</div>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<Card className="dashboard-owner-panel">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
|
||||
<p className="section-subtitle mt-1.5 mb-0 mx-0">
|
||||
Owner-only package view with VirusTotal, static scan, and verification state.
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="dashboard-collection-title">Plugins</h2>
|
||||
<Button asChild size="sm" className="dashboard-section-action">
|
||||
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{packages.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
@@ -291,50 +280,27 @@ function Dashboard() {
|
||||
<strong>No plugins yet.</strong> Publish your first plugin release to validate and
|
||||
distribute it.
|
||||
</div>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Plugin</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{packages.map((pkg) => (
|
||||
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
|
||||
const status = skillDashboardStatus(skill);
|
||||
const latestScanLabel = skill.latestVersion
|
||||
? [
|
||||
releaseStatusLabel("VT", skill.latestVersion.vtStatus, "unknown"),
|
||||
releaseStatusLabel("OpenClaw", skill.latestVersion.llmStatus),
|
||||
releaseStatusLabel("Static", skill.latestVersion.staticScanStatus),
|
||||
]
|
||||
: ["No release scan data"];
|
||||
const showRescan =
|
||||
status.key !== "visible" ||
|
||||
skill.latestVersion?.vtStatus === "suspicious" ||
|
||||
skill.latestVersion?.vtStatus === "malicious" ||
|
||||
skill.latestVersion?.llmStatus === "suspicious" ||
|
||||
skill.latestVersion?.llmStatus === "malicious" ||
|
||||
skill.latestVersion?.staticScanStatus === "suspicious" ||
|
||||
skill.latestVersion?.staticScanStatus === "malicious";
|
||||
const detailParams = { owner: ownerHandle ?? "unknown", slug: skill.slug };
|
||||
const settingsHref = `/${encodeURIComponent(detailParams.owner)}/${encodeURIComponent(
|
||||
skill.slug,
|
||||
)}/settings`;
|
||||
|
||||
return (
|
||||
<div className="dashboard-list-row">
|
||||
@@ -342,74 +308,209 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
<div className="dashboard-list-title">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
params={detailParams}
|
||||
className="dashboard-skill-name"
|
||||
>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<span className="dashboard-list-id">/{skill.slug}</span>
|
||||
<Badge variant={status.variant}>
|
||||
{status.key === "pending" ? <Clock className="h-3 w-3" aria-hidden="true" /> : null}
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="dashboard-inline-status-note">{status.description}</span>
|
||||
{latestScanLabel.map((label) => (
|
||||
<span key={label} className="dashboard-inline-status-note">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
<StatusChipWithTooltip status={status} />
|
||||
</div>
|
||||
<div className="dashboard-row-actions">
|
||||
<Button asChild size="sm">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Version
|
||||
<RowMenu
|
||||
kind="skill"
|
||||
targetId={skill._id}
|
||||
targetLabel={skill.displayName}
|
||||
settingsHref={settingsHref}
|
||||
statusLabel={status.label}
|
||||
rescanState={skill.rescanState ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChipWithTooltip({
|
||||
status,
|
||||
}: {
|
||||
status: {
|
||||
key?: string;
|
||||
label: string;
|
||||
description: string;
|
||||
variant: "default" | "pending" | "warning" | "destructive" | "success";
|
||||
};
|
||||
}) {
|
||||
const showInfo = status.label !== "Visible";
|
||||
|
||||
return (
|
||||
<Badge variant={status.variant} className="dashboard-status-chip">
|
||||
{status.key === "pending" ? <Clock className="h-3 w-3" aria-hidden="true" /> : null}
|
||||
{status.label}
|
||||
{showInfo ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-status-info"
|
||||
aria-label={`${status.label} status reason`}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="end">
|
||||
{status.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function packageDashboardStatus(pkg: DashboardPackage): {
|
||||
label: string;
|
||||
description: string;
|
||||
variant: "default" | "pending" | "warning" | "destructive" | "success";
|
||||
} {
|
||||
const releaseStatuses = new Set([
|
||||
pkg.latestRelease?.vtStatus,
|
||||
pkg.latestRelease?.llmStatus,
|
||||
pkg.latestRelease?.staticScanStatus,
|
||||
]);
|
||||
if (pkg.scanStatus === "malicious" || releaseStatuses.has("malicious")) {
|
||||
return {
|
||||
label: "Blocked",
|
||||
description: "Security checks found malicious content.",
|
||||
variant: "destructive",
|
||||
};
|
||||
}
|
||||
if (pkg.scanStatus === "suspicious" || releaseStatuses.has("suspicious")) {
|
||||
return {
|
||||
label: "Suspicious",
|
||||
description: "Security checks flagged this plugin for review.",
|
||||
variant: "warning",
|
||||
};
|
||||
}
|
||||
if (pkg.scanStatus === "pending" || pkg.pendingReview) {
|
||||
return {
|
||||
label: "Pending checks",
|
||||
description: "Security verification is still running.",
|
||||
variant: "pending",
|
||||
};
|
||||
}
|
||||
if (pkg.scanStatus === "clean") {
|
||||
return {
|
||||
label: "Visible",
|
||||
description: "Available on public catalog surfaces.",
|
||||
variant: "success",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Unknown",
|
||||
description: "Open the plugin for the latest release and security details.",
|
||||
variant: "default",
|
||||
};
|
||||
}
|
||||
|
||||
function PackageRow({ pkg }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
const status = packageDashboardStatus(pkg);
|
||||
|
||||
return (
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
|
||||
{pkg.displayName}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
|
||||
<ShieldCheck className="h-3 w-3" aria-hidden="true" />
|
||||
Security
|
||||
</Link>
|
||||
</Button>
|
||||
{showRescan ? (
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
<StatusChipWithTooltip status={status} />
|
||||
</div>
|
||||
<RowMenu
|
||||
kind="plugin"
|
||||
targetId={pkg._id}
|
||||
targetLabel={pkg.displayName}
|
||||
settingsHref={`/plugins/${encodeURIComponent(pkg.name)}`}
|
||||
statusLabel={status.label}
|
||||
rescanState={pkg.rescanState ?? null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function canShowDashboardRescan(statusLabel: string, state: DashboardRescanState | null) {
|
||||
if (statusLabel === "Visible") return false;
|
||||
if (!state) return true;
|
||||
return state.canRequest && !state.inProgressRequest && state.remainingRequests > 0;
|
||||
}
|
||||
|
||||
function RowMenu({
|
||||
kind,
|
||||
targetId,
|
||||
targetLabel,
|
||||
settingsHref,
|
||||
statusLabel,
|
||||
rescanState,
|
||||
}: {
|
||||
kind: "skill" | "plugin";
|
||||
targetId: string;
|
||||
targetLabel: string;
|
||||
settingsHref: string;
|
||||
statusLabel: string;
|
||||
rescanState: DashboardRescanState | null;
|
||||
}) {
|
||||
const requestSkillRescan = useMutation(api.skills.requestRescan);
|
||||
const requestPluginRescan = useMutation(api.packages.requestRescan);
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const showRescan = canShowDashboardRescan(statusLabel, rescanState);
|
||||
|
||||
async function requestRescan() {
|
||||
if (!showRescan || isRequesting) return;
|
||||
setIsRequesting(true);
|
||||
try {
|
||||
if (kind === "skill") {
|
||||
await requestSkillRescan({ skillId: targetId as Doc<"skills">["_id"] });
|
||||
} else {
|
||||
await requestPluginRescan({ packageId: targetId as Doc<"packages">["_id"] });
|
||||
}
|
||||
toast.success(`Rescan requested for ${targetLabel}.`);
|
||||
} catch (error) {
|
||||
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
|
||||
} finally {
|
||||
setIsRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-row-menu">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
title="Owner rescan requests are handled by the rescan request backend."
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${targetLabel}`}
|
||||
>
|
||||
<RotateCw className="h-3 w-3" aria-hidden="true" />
|
||||
Request Rescan
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/$owner/$slug" params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="dashboard-row-menu-content">
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={settingsHref}>
|
||||
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||
Settings
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{showRescan ? (
|
||||
<DropdownMenuItem disabled={isRequesting} onSelect={() => void requestRescan()}>
|
||||
<RotateCw className="h-4 w-4" aria-hidden="true" />
|
||||
{isRequesting ? "Requesting..." : "Request rescan"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -422,6 +523,11 @@ function skillDashboardStatus(skill: DashboardSkill): {
|
||||
} {
|
||||
const flags = skill.moderationFlags ?? [];
|
||||
const reason = skill.moderationReason ?? "";
|
||||
const versionStatuses = new Set([
|
||||
skill.latestVersion?.vtStatus,
|
||||
skill.latestVersion?.llmStatus,
|
||||
skill.latestVersion?.staticScanStatus,
|
||||
]);
|
||||
if (skill.moderationStatus === "removed") {
|
||||
return {
|
||||
key: "removed",
|
||||
@@ -430,7 +536,11 @@ function skillDashboardStatus(skill: DashboardSkill): {
|
||||
variant: "destructive",
|
||||
};
|
||||
}
|
||||
if (flags.includes("blocked.malware") || skill.moderationVerdict === "malicious") {
|
||||
if (
|
||||
flags.includes("blocked.malware") ||
|
||||
skill.moderationVerdict === "malicious" ||
|
||||
versionStatuses.has("malicious")
|
||||
) {
|
||||
return {
|
||||
key: "blocked",
|
||||
label: "Blocked",
|
||||
@@ -462,7 +572,8 @@ function skillDashboardStatus(skill: DashboardSkill): {
|
||||
if (
|
||||
skill.isSuspicious ||
|
||||
flags.includes("flagged.suspicious") ||
|
||||
skill.moderationVerdict === "suspicious"
|
||||
skill.moderationVerdict === "suspicious" ||
|
||||
versionStatuses.has("suspicious")
|
||||
) {
|
||||
return {
|
||||
key: "suspicious",
|
||||
@@ -487,196 +598,3 @@ function skillDashboardStatus(skill: DashboardSkill): {
|
||||
variant: "success",
|
||||
};
|
||||
}
|
||||
|
||||
function scanStatusLabel(status: string | null | undefined) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Pending scan";
|
||||
case "clean":
|
||||
return "Scan clean";
|
||||
case "suspicious":
|
||||
return "Suspicious";
|
||||
case "malicious":
|
||||
return "Blocked";
|
||||
case "not-run":
|
||||
return "Scan not run";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseStatusLabel(
|
||||
label: string,
|
||||
status: string | null | undefined,
|
||||
emptyLabel = "not started",
|
||||
) {
|
||||
return `${label}: ${status?.trim() ? status : emptyLabel}`;
|
||||
}
|
||||
|
||||
function PackageStatusTag({
|
||||
label,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
tone: "default" | "pending" | "warning" | "danger" | "success";
|
||||
}) {
|
||||
const variant =
|
||||
tone === "pending"
|
||||
? "pending"
|
||||
: tone === "warning"
|
||||
? "warning"
|
||||
: tone === "danger"
|
||||
? "destructive"
|
||||
: tone === "success"
|
||||
? "success"
|
||||
: "default";
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
}
|
||||
|
||||
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
const scanLabel = scanStatusLabel(pkg.scanStatus);
|
||||
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
|
||||
const sourceLabel = pkg.sourceRepo
|
||||
?.replace(/^https?:\/\/github\.com\//, "")
|
||||
.replace(/\.git$/, "");
|
||||
const showRescan =
|
||||
pkg.scanStatus === "pending" ||
|
||||
pkg.scanStatus === "suspicious" ||
|
||||
pkg.scanStatus === "malicious" ||
|
||||
pkg.latestRelease?.vtStatus === "suspicious" ||
|
||||
pkg.latestRelease?.vtStatus === "malicious" ||
|
||||
pkg.latestRelease?.llmStatus === "suspicious" ||
|
||||
pkg.latestRelease?.llmStatus === "malicious" ||
|
||||
pkg.latestRelease?.staticScanStatus === "suspicious" ||
|
||||
pkg.latestRelease?.staticScanStatus === "malicious";
|
||||
const scanTone =
|
||||
pkg.scanStatus === "pending"
|
||||
? "pending"
|
||||
: pkg.scanStatus === "suspicious"
|
||||
? "warning"
|
||||
: pkg.scanStatus === "malicious"
|
||||
? "danger"
|
||||
: pkg.scanStatus === "clean"
|
||||
? "success"
|
||||
: "default";
|
||||
const staticTone =
|
||||
pkg.latestRelease?.staticScanStatus === "suspicious"
|
||||
? "warning"
|
||||
: pkg.latestRelease?.staticScanStatus === "malicious"
|
||||
? "danger"
|
||||
: pkg.latestRelease?.staticScanStatus === "clean"
|
||||
? "success"
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
|
||||
{pkg.displayName}
|
||||
</Link>
|
||||
<span className="dashboard-list-id">{pkg.name}</span>
|
||||
</div>
|
||||
<div className="dashboard-inline-tags">
|
||||
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
|
||||
<PackageStatusTag label={pkg.channel} tone="default" />
|
||||
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
|
||||
{pkg.verification?.tier ? (
|
||||
<PackageStatusTag label={pkg.verification.tier} tone="default" />
|
||||
) : null}
|
||||
{pkg.latestRelease?.staticScanStatus ? (
|
||||
<PackageStatusTag
|
||||
label={`Static ${pkg.latestRelease.staticScanStatus}`}
|
||||
tone={staticTone}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(pkg.stats.downloads)}
|
||||
</span>
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
|
||||
</span>
|
||||
<span>
|
||||
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
|
||||
</span>
|
||||
{pkg.runtimeId ? (
|
||||
<span>
|
||||
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
|
||||
</span>
|
||||
) : null}
|
||||
{sourceLabel ? (
|
||||
<span>
|
||||
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel(
|
||||
"VT",
|
||||
pkg.latestRelease?.vtStatus,
|
||||
pkg.scanStatus === "pending" ? "pending" : "unknown",
|
||||
)}
|
||||
</span>
|
||||
<span className="dashboard-inline-status-item">
|
||||
<CheckCircle2 size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
|
||||
</span>
|
||||
<span className="dashboard-inline-status-item">
|
||||
<AlertTriangle size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dashboard-row-actions">
|
||||
<Button asChild size="sm">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
sourceRepo: pkg.sourceRepo ?? undefined,
|
||||
}}
|
||||
>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Release
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }}>
|
||||
<ShieldCheck className="h-3 w-3" aria-hidden="true" />
|
||||
Security
|
||||
</Link>
|
||||
</Button>
|
||||
{showRescan ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
title="Owner rescan requests are handled by the rescan request backend."
|
||||
>
|
||||
<RotateCw className="h-3 w-3" aria-hidden="true" />
|
||||
Request Rescan
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ export function ImportGitHub() {
|
||||
description="You need to be signed in to import skills from GitHub."
|
||||
>
|
||||
{!isLoading ? (
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
<SignInButton />
|
||||
) : null}
|
||||
</EmptyState>
|
||||
</Container>
|
||||
|
||||
+326
-330
@@ -1,13 +1,20 @@
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { AlertTriangle, ExternalLink, Copy, Check, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { AlertTriangle, ExternalLink, Download } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
|
||||
import { DetailSecuritySummary } from "../../components/DetailSecuritySummary";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { InstallCopyButton } from "../../components/InstallCopyButton";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { MarkdownPreview } from "../../components/MarkdownPreview";
|
||||
import { SecurityScanResults } from "../../components/SkillSecurityScanResults";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
|
||||
import { getUserFacingConvexError } from "../../lib/convexError";
|
||||
import { formatRetryDelay } from "../../lib/formatRetryDelay";
|
||||
import {
|
||||
fetchPackageDetail,
|
||||
@@ -19,13 +26,12 @@ import {
|
||||
type PackageVersionDetail,
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
type PluginDetailRateLimitState =
|
||||
| {
|
||||
scope: "detail" | "metadata";
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
| null;
|
||||
type PluginDetailRateLimitState = {
|
||||
scope: "detail" | "metadata";
|
||||
retryAfterSeconds: number | null;
|
||||
} | null;
|
||||
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
@@ -143,63 +149,6 @@ function VerifiedBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string): boolean {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
const ok = document.execCommand("copy");
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
onClick={() => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
void navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setState("copied");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
if (fallbackCopy(text)) {
|
||||
setState("copied");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
} else {
|
||||
setState("failed");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
}
|
||||
});
|
||||
} else if (fallbackCopy(text)) {
|
||||
setState("copied");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
} else {
|
||||
setState("failed");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
}
|
||||
}}
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
{state === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{state === "copied" ? "Copied" : state === "failed" ? "Failed" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const CAPABILITY_LABELS: Record<string, string> = {
|
||||
executesCode: "Executes code",
|
||||
runtimeId: "Runtime ID",
|
||||
@@ -235,6 +184,12 @@ function PluginDetailRoute() {
|
||||
const { name } = Route.useParams();
|
||||
const { detail, version, readme, rateLimited } = Route.useLoaderData() as PluginDetailLoaderData;
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const requestPluginRescan = useMutation(api.packages.requestRescan);
|
||||
const rescanState = useQuery(
|
||||
api.packages.getOwnerRescanStateByName,
|
||||
isAuthenticated && detail.package ? { name: detail.package.name } : "skip",
|
||||
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
|
||||
|
||||
if (pathname.includes("/security/")) {
|
||||
return <Outlet />;
|
||||
@@ -286,6 +241,26 @@ function PluginDetailRoute() {
|
||||
const capabilities = latestRelease?.capabilities ?? pkg.capabilities;
|
||||
const compatibility = latestRelease?.compatibility ?? pkg.compatibility;
|
||||
const verification = latestRelease?.verification ?? pkg.verification;
|
||||
const requestRescan = async () => {
|
||||
const packageId = (pkg as { _id?: Id<"packages"> })._id;
|
||||
if (!packageId) {
|
||||
toast.error("Could not request a rescan for this plugin.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await requestPluginRescan({ packageId });
|
||||
toast.success("Rescan requested.", {
|
||||
action: {
|
||||
label: "Dashboard",
|
||||
onClick: () => {
|
||||
window.location.href = "/dashboard";
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
|
||||
}
|
||||
};
|
||||
|
||||
const capEntries = capabilities
|
||||
? Object.entries(capabilities).filter(
|
||||
@@ -299,12 +274,56 @@ function PluginDetailRoute() {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{/* Header card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
<main className="section detail-page-section">
|
||||
<DetailPageShell>
|
||||
<DetailHero
|
||||
main={
|
||||
<div className="skill-hero-title">
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="skill-page-title">{pkg.displayName}</h1>
|
||||
{pkg.latestVersion ? (
|
||||
<span className="plugin-version-badge">v{pkg.latestVersion}</span>
|
||||
) : null}
|
||||
{pkg.latestVersion ? (
|
||||
<div className="skill-title-actions">
|
||||
<Button asChild variant="outline" size="sm" className="no-underline">
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="section-subtitle">{pkg.summary ?? "No summary provided."}</p>
|
||||
|
||||
<div className="skill-hero-inline-meta">
|
||||
<div className="skill-hero-stats-row">
|
||||
<span className="stat font-mono text-xs">{pkg.name}</span>
|
||||
{pkg.runtimeId ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{owner?.handle ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<Link
|
||||
to="/u/$handle"
|
||||
params={{ handle: owner.handle }}
|
||||
className="text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
by @{owner.handle}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-hero-badges">
|
||||
<Badge>{familyLabel(pkg.family)}</Badge>
|
||||
{verification?.tier ? (
|
||||
<Badge variant="compact">{verification.tier.replace(/-/g, " ")}</Badge>
|
||||
@@ -317,274 +336,251 @@ function PluginDetailRoute() {
|
||||
<VerifiedBadge />
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="mb-1 break-words font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
{pkg.displayName}
|
||||
{pkg.latestVersion ? (
|
||||
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
|
||||
v{pkg.latestVersion}
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<p className="mb-2 break-words text-sm text-[color:var(--ink-soft)]">
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="break-all font-mono text-xs">{pkg.name}</span>
|
||||
{pkg.runtimeId ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>
|
||||
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{owner?.handle ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<Link
|
||||
to="/u/$handle"
|
||||
params={{ handle: owner.handle }}
|
||||
className="text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
by @{owner.handle}
|
||||
</Link>
|
||||
</>
|
||||
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
|
||||
<Badge variant="accent">
|
||||
Community code plugin. Review compatibility and verification before install.
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
|
||||
<Badge variant="accent" className="mt-3 self-start">
|
||||
Community code plugin. Review compatibility and verification before install.
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{/* Install */}
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
|
||||
<pre className="plugin-detail-code-block min-w-0 flex-1 font-mono text-xs text-[color:var(--ink)]">
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
<CopyButton text={installSnippet} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Release */}
|
||||
{pkg.latestVersion ? (
|
||||
<div className="mt-3 flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
|
||||
<span className="text-sm">
|
||||
Latest release: <strong>v{pkg.latestVersion}</strong>
|
||||
</span>
|
||||
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download zip
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Capabilities */}
|
||||
{capEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
<CopyButton text={JSON.stringify(capabilities, null, 2)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{capEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Compatibility */}
|
||||
{compatEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Compatibility</CardTitle>
|
||||
<CopyButton text={JSON.stringify(compatibility, null, 2)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{compatEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Security Scan */}
|
||||
{latestRelease ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<SecurityScanResults
|
||||
sha256hash={latestRelease.sha256hash ?? undefined}
|
||||
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
|
||||
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
|
||||
staticFindings={latestRelease.staticScan?.findings ?? []}
|
||||
</div>
|
||||
}
|
||||
sidebar={
|
||||
<>
|
||||
{latestRelease ? (
|
||||
<DetailSecuritySummary
|
||||
scannerBasePath={`/plugins/${encodeURIComponent(name)}/security`}
|
||||
sha256hash={latestRelease.sha256hash ?? null}
|
||||
vtAnalysis={latestRelease.vtAnalysis ?? null}
|
||||
llmAnalysis={latestRelease.llmAnalysis ?? null}
|
||||
staticScan={latestRelease.staticScan ?? null}
|
||||
rescanState={rescanState ?? null}
|
||||
onRequestRescan={rescanState ? requestRescan : null}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Verification */}
|
||||
{verification && !isEmptyObject(verification) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{verification.tier ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.tier.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.scope.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceRepo
|
||||
? (() => {
|
||||
const raw = verification.sourceRepo;
|
||||
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
|
||||
const display = href.replace(/^https?:\/\//, "");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.hasProvenance !== undefined ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.hasProvenance ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scanStatus ? (
|
||||
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Readme */}
|
||||
) : null}
|
||||
<Card className="skill-install-command-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Install</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="skill-install-command-wrap">
|
||||
<pre className="skill-install-command">
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
<InstallCopyButton
|
||||
text={installSnippet}
|
||||
ariaLabel="Copy plugin install command"
|
||||
showLabel={false}
|
||||
className="skill-install-command-inline-button"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{readme ? (
|
||||
<Card>
|
||||
<Card className="tab-card">
|
||||
<CardHeader>
|
||||
<CardTitle>README</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MarkdownPreview>{readme}</MarkdownPreview>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
{capEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(capabilities, null, 2)}
|
||||
ariaLabel="Copy capabilities JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{capEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Compatibility */}
|
||||
{compatEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Compatibility</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(compatibility, null, 2)}
|
||||
ariaLabel="Copy compatibility JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{compatEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Verification */}
|
||||
{verification && !isEmptyObject(verification) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{verification.tier ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.tier.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.scope.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceRepo
|
||||
? (() => {
|
||||
const raw = verification.sourceRepo;
|
||||
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
|
||||
const display = href.replace(/^https?:\/\//, "");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.hasProvenance !== undefined ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.hasProvenance ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scanStatus ? (
|
||||
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
</DetailHero>
|
||||
</DetailPageShell>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const Route = createFileRoute("/plugins/$name/security/$scanner")({
|
||||
scanner === "virustotal"
|
||||
? "VirusTotal"
|
||||
: scanner === "openclaw"
|
||||
? "OpenClaw"
|
||||
? "ClawScan"
|
||||
: "Static analysis";
|
||||
return {
|
||||
meta: [
|
||||
@@ -130,7 +130,6 @@ function PluginSecurityScannerRoute() {
|
||||
ownerUserId: null,
|
||||
ownerPublisherId: null,
|
||||
detailPath: `/plugins/${encodeURIComponent(name)}`,
|
||||
securityBasePath: `/plugins/${encodeURIComponent(name)}/security`,
|
||||
}}
|
||||
sha256hash={release.sha256hash ?? null}
|
||||
vtAnalysis={release.vtAnalysis ?? null}
|
||||
|
||||
@@ -349,7 +349,7 @@ export function Upload() {
|
||||
title={`Sign in to publish a ${contentLabel}`}
|
||||
description="You need to be signed in to publish skills on ClawHub."
|
||||
>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
<SignInButton />
|
||||
</EmptyState>
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
@@ -165,7 +165,7 @@ export function Settings() {
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<span>Sign in to access settings.</span>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
<SignInButton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
|
||||
@@ -32,7 +32,7 @@ function Stars() {
|
||||
title="Sign in to see your highlights"
|
||||
description="Star skills for quick access later."
|
||||
>
|
||||
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
|
||||
<SignInButton />
|
||||
</EmptyState>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+321
-275
@@ -642,6 +642,12 @@ a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Global focus indicator for keyboard navigation */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -738,7 +744,7 @@ code {
|
||||
|
||||
.navbar-inner {
|
||||
width: 100%;
|
||||
padding: 0 var(--space-5);
|
||||
padding: 0 clamp(48px, 4vw, 88px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -746,7 +752,7 @@ code {
|
||||
|
||||
.navbar-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(148px, auto) minmax(200px, 1fr) auto;
|
||||
grid-template-columns: minmax(148px, auto) minmax(260px, 640px) auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
@@ -755,7 +761,7 @@ code {
|
||||
|
||||
.navbar-search {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-width: 640px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
@@ -854,7 +860,7 @@ code {
|
||||
}
|
||||
|
||||
.navbar-tabs {
|
||||
display: flex;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
@@ -869,6 +875,15 @@ code {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-top-links {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-tabs-primary,
|
||||
.navbar-tabs-secondary {
|
||||
display: inline-flex;
|
||||
@@ -877,9 +892,9 @@ code {
|
||||
}
|
||||
|
||||
.navbar-tabs-secondary {
|
||||
margin-left: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--line);
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.navbar-tab {
|
||||
@@ -898,7 +913,8 @@ code {
|
||||
|
||||
.navbar-tab:hover {
|
||||
color: var(--ink);
|
||||
background: var(--hover-bg);
|
||||
background: var(--surface-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar-tab:hover svg {
|
||||
@@ -909,13 +925,14 @@ code {
|
||||
.navbar-tab[data-status="active"] {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
background: var(--active-bg);
|
||||
background: var(--surface-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar-tab.active svg,
|
||||
.navbar-tab[data-status="active"] svg {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.navbar-tab-secondary {
|
||||
@@ -1118,44 +1135,27 @@ code {
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.theme-cycle-group {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
grid-template-columns: repeat(2, 32px);
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.theme-cycle-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border: 0;
|
||||
border-radius: var(--r-btn);
|
||||
background: var(--surface);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-cycle-button:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--border-ui-hover);
|
||||
background: var(--surface-muted);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.theme-cycle-button:focus-visible {
|
||||
@@ -1163,31 +1163,6 @@ code {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-mode-toggle button {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--r-btn);
|
||||
cursor: pointer;
|
||||
color: var(--ink-soft);
|
||||
opacity: 0.4;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-mode-toggle button:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.theme-mode-toggle button[data-state="on"] {
|
||||
opacity: 1;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.user-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1363,6 +1338,19 @@ code {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section.detail-page-section {
|
||||
max-width: none;
|
||||
padding-right: clamp(48px, 4vw, 88px);
|
||||
padding-left: clamp(48px, 4vw, 88px);
|
||||
}
|
||||
|
||||
.security-scanner-layout {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.upload-shell {
|
||||
position: relative;
|
||||
}
|
||||
@@ -2778,8 +2766,8 @@ code {
|
||||
}
|
||||
|
||||
.hero-install-code {
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 247, 0.9));
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-muted);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 0.9rem;
|
||||
@@ -2797,7 +2785,7 @@ code {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
tab-size: 2;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ink) 8%, transparent);
|
||||
}
|
||||
|
||||
/* @deprecated — use <Badge> from components/ui/badge.tsx */
|
||||
@@ -3004,7 +2992,71 @@ code {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.skill-hero {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-main,
|
||||
.detail-sidebar {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-sidebar {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.detail-sidebar .detail-meta-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.detail-sidebar .meta-bar-stats,
|
||||
.detail-sidebar .meta-bar-details,
|
||||
.detail-sidebar .meta-bar-footer {
|
||||
flex: 0 1 auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.detail-sidebar .meta-bar-details,
|
||||
.detail-sidebar .meta-bar-footer {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.detail-sidebar .meta-bar-footer {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.detail-sidebar .meta-bar-footer > .btn {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
.detail-layout:has(.detail-sidebar) {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.detail-sidebar {
|
||||
position: sticky;
|
||||
top: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Plugin Detail Page ── */
|
||||
@@ -3191,34 +3243,64 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
.skill-hero {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.skill-hero-header {
|
||||
.skill-hero-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(180px, 220px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.skill-hero-main {
|
||||
display: grid;
|
||||
gap: 40px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-hero-main-extra {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* .skill-hero-top.has-plugin no longer uses a 2-column grid, so no
|
||||
collapse override is needed here. */
|
||||
|
||||
.skill-hero-title {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1 1 min(360px, 100%);
|
||||
}
|
||||
|
||||
.skill-hero-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-page-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.05;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.skill-title-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.skill-settings-link {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.skill-hero-note {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
@@ -3250,10 +3332,10 @@ code {
|
||||
}
|
||||
|
||||
.bundle-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.7));
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 30px rgba(37, 31, 26, 0.08);
|
||||
}
|
||||
|
||||
@@ -3282,8 +3364,9 @@ code {
|
||||
.bundle-includes span {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: var(--accent-deep);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-muted);
|
||||
color: var(--ink);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
@@ -3294,8 +3377,8 @@ code {
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.bundle-card .hero-install-code {
|
||||
@@ -3352,6 +3435,29 @@ code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-hero-sidebar > .card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-hero-sidebar .skill-install-panel {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.skill-hero-sidebar .skill-install-panel-copy {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.skill-hero-sidebar .skill-install-command-card {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.skill-hero-sidebar .skill-install-command {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.skill-hero-sidebar-meta {
|
||||
@@ -3423,31 +3529,19 @@ code {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.skill-install-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.skill-install-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(25, 21, 19, 0.96), rgba(13, 13, 15, 0.98)),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0));
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.18);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-theme-resolved="light"] .skill-install-panel {
|
||||
border-color: rgba(78, 49, 28, 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 250, 244, 0.98), rgba(248, 240, 230, 0.98)),
|
||||
radial-gradient(circle at top right, rgba(197, 126, 69, 0.14), rgba(197, 126, 69, 0));
|
||||
box-shadow: 0 18px 42px rgba(58, 35, 20, 0.08);
|
||||
border-color: var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.skill-install-panel-header {
|
||||
@@ -3627,12 +3721,10 @@ code {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(12, 12, 14, 0.96), rgba(19, 19, 22, 0.98)),
|
||||
radial-gradient(circle at top right, rgba(220, 38, 38, 0.14), rgba(220, 38, 38, 0));
|
||||
color: #f8f2e9;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-muted);
|
||||
color: var(--ink);
|
||||
padding: 16px 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
@@ -3641,7 +3733,7 @@ code {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -3659,33 +3751,57 @@ code {
|
||||
|
||||
.skill-install-command-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
[data-theme-resolved="light"] .skill-install-command-card {
|
||||
border-color: rgba(15, 12, 10, 0.08);
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.skill-install-command-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
[data-theme-resolved="light"] .skill-install-command-card {
|
||||
border-color: var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.skill-install-command-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-install-command-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-install-command-wrap .skill-install-command {
|
||||
padding-right: 62px;
|
||||
}
|
||||
|
||||
.skill-install-command.skill-install-prompt-compact code {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.skill-install-command-inline-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 34px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.skill-install-command-caption {
|
||||
margin: 0;
|
||||
font-size: 0.84rem;
|
||||
@@ -3720,7 +3836,7 @@ code {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-transform: lowercase;
|
||||
text-transform: none;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
@@ -3754,11 +3870,17 @@ code {
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.skill-install-grid {
|
||||
.skill-hero-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.skill-detail-stack > .detail-layout {
|
||||
width: calc(100% - 384px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy compatibility — keep for any existing references */
|
||||
.skill-hero-cta {
|
||||
display: flex;
|
||||
@@ -4502,11 +4624,12 @@ code {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-top-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-tabs {
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
margin: 0 -18px;
|
||||
padding: 0 18px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-tabs-secondary {
|
||||
@@ -4589,19 +4712,26 @@ code {
|
||||
padding: 30px 18px 62px;
|
||||
}
|
||||
|
||||
.security-scanner-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section.detail-page-section {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.section-cta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.skill-hero-header {
|
||||
.skill-hero-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skill-hero-sidebar {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.skill-hero-sidebar-meta {
|
||||
@@ -5153,7 +5283,7 @@ code {
|
||||
}
|
||||
|
||||
.markdown a:hover {
|
||||
color: var(--accent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown ul,
|
||||
@@ -5294,33 +5424,6 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes theme-circle-transition {
|
||||
0% {
|
||||
clip-path: circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
|
||||
}
|
||||
100% {
|
||||
clip-path: circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%));
|
||||
}
|
||||
}
|
||||
|
||||
html.theme-transition {
|
||||
view-transition-name: theme;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: Custom view-transition pseudo-element for theme switch. */
|
||||
html.theme-transition::view-transition-old(theme) {
|
||||
mix-blend-mode: normal;
|
||||
animation: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: Custom view-transition pseudo-element for theme switch. */
|
||||
html.theme-transition::view-transition-new(theme) {
|
||||
mix-blend-mode: normal;
|
||||
z-index: 2;
|
||||
animation: theme-circle-transition 0.45s ease-out forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -5332,14 +5435,6 @@ html.theme-transition::view-transition-new(theme) {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduce-motion override */
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: Custom view-transition pseudo-element for theme switch. */
|
||||
html.theme-transition::view-transition-old(theme),
|
||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: Custom view-transition pseudo-element for theme switch. */
|
||||
html.theme-transition::view-transition-new(theme) {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduce-motion override */
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard styles */
|
||||
@@ -5370,11 +5465,6 @@ html.theme-transition::view-transition-new(theme) {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-collection-block + .dashboard-collection-block {
|
||||
border-top: 1px solid var(--card-border);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.dashboard-collection-title {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.1;
|
||||
@@ -5388,6 +5478,10 @@ html.theme-transition::view-transition-new(theme) {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-section-action {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.dashboard-inline-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5409,27 +5503,19 @@ html.theme-transition::view-transition-new(theme) {
|
||||
.dashboard-list {
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-list-header,
|
||||
.dashboard-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1.15fr) minmax(0, 1fr) minmax(240px, 0.95fr) auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.dashboard-list-header {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(0, 1.35fr) max-content 32px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
margin-inline: -12px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dashboard-list-row {
|
||||
@@ -5442,7 +5528,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
.dashboard-list-row:hover {
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.dashboard-list-primary {
|
||||
@@ -5468,28 +5554,6 @@ html.theme-transition::view-transition-new(theme) {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dashboard-inline-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-inline-metrics {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.dashboard-inline-metrics span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-list-summary {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
@@ -5497,70 +5561,72 @@ html.theme-transition::view-transition-new(theme) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.dashboard-list-status {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
align-content: center;
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-inline-status-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--card-border);
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.dashboard-skill-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-skill-name:hover {
|
||||
color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-inline-status-note {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-muted);
|
||||
.dashboard-status-chip {
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-tag-warning {
|
||||
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||
color: #b45309;
|
||||
.dashboard-status-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
cursor: default;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.dashboard-tag-danger {
|
||||
background: color-mix(in srgb, #ef4444 18%, transparent);
|
||||
color: #b91c1c;
|
||||
.dashboard-status-info:hover,
|
||||
.dashboard-status-info:focus-visible {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-tag-success {
|
||||
background: color-mix(in srgb, #10b981 18%, transparent);
|
||||
color: #047857;
|
||||
.dashboard-status-info:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-row-actions {
|
||||
.dashboard-row-menu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
max-width: 260px;
|
||||
justify-self: end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-row-menu-content {
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@@ -5582,12 +5648,10 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-list-header,
|
||||
.dashboard-list-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(0, 1fr) max-content 32px;
|
||||
}
|
||||
|
||||
.dashboard-list-header span:nth-child(2),
|
||||
.dashboard-list-summary {
|
||||
display: none;
|
||||
}
|
||||
@@ -5605,22 +5669,9 @@ html.theme-transition::view-transition-new(theme) {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-list-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-list-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-row-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-row-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
grid-template-columns: minmax(0, 1fr) max-content 32px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-owner-grid {
|
||||
@@ -6423,9 +6474,9 @@ html.theme-transition::view-transition-new(theme) {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.skill-hero-title h1 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
.skill-page-title {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.skill-hero-note {
|
||||
@@ -7787,8 +7838,8 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
.footer-bottom a:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: inherit;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -8274,11 +8325,6 @@ html.theme-transition::view-transition-new(theme) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.theme-mode-toggle {
|
||||
min-width: 104px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.theme-picker-label {
|
||||
display: none;
|
||||
@@ -8286,14 +8332,9 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) and (min-width: 761px) {
|
||||
.theme-picker-desktop,
|
||||
.theme-mode-toggle {
|
||||
.theme-picker-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-cycle-group {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -9920,14 +9961,11 @@ html.theme-transition::view-transition-new(theme) {
|
||||
|
||||
/* Theme toggle buttons */
|
||||
.theme-family-button,
|
||||
.theme-cycle-button,
|
||||
.theme-mode-toggle button,
|
||||
.theme-mode-toggle {
|
||||
.theme-cycle-button {
|
||||
border-radius: var(--r-btn) !important;
|
||||
}
|
||||
|
||||
/* Dashboard/management action buttons */
|
||||
.dashboard-row-actions .btn,
|
||||
/* Management action buttons */
|
||||
.meta-bar-footer > .btn {
|
||||
border-radius: var(--r-btn) !important;
|
||||
}
|
||||
@@ -9998,3 +10036,11 @@ a[class*="rounded-full"] {
|
||||
.home-v2-search-go {
|
||||
border-radius: var(--r-btn) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 761px) {
|
||||
.navbar-inner,
|
||||
.section.detail-page-section {
|
||||
padding-right: clamp(48px, 4vw, 88px);
|
||||
padding-left: clamp(48px, 4vw, 88px);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user