mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8d53fa5f | ||
|
|
f6661a7a4c | ||
|
|
d2c2e7b509 | ||
|
|
70f6a435e2 | ||
|
|
929c9de50e | ||
|
|
ea67295794 | ||
|
|
f5a3cba962 | ||
|
|
b2a173ab75 | ||
|
|
5d01b99adb | ||
|
|
5dc834c27e | ||
|
|
2e74c9bad2 | ||
|
|
82b9a69dad | ||
|
|
064804e2d3 | ||
|
|
04a862d2b2 | ||
|
|
a7fc4bbae2 | ||
|
|
4701c555f3 | ||
|
|
c1f167721b | ||
|
|
1a94744484 | ||
|
|
9a5cfeee85 | ||
|
|
e69b7d4501 | ||
|
|
ecf09b868a | ||
|
|
4d16472f5b |
@@ -12,6 +12,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
@@ -22,6 +24,24 @@ jobs:
|
||||
- name: Peer deps
|
||||
run: bun run check:peers
|
||||
|
||||
- name: Format
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
mapfile -d '' changed_files < <(
|
||||
git diff --name-only --diff-filter=ACMR -z \
|
||||
"${{ github.event.pull_request.base.sha }}" \
|
||||
"${{ github.event.pull_request.head.sha }}" \
|
||||
-- \
|
||||
'*.css' '*.js' '*.jsx' '*.json' '*.md' '*.mjs' '*.ts' '*.tsx' '*.yaml' '*.yml'
|
||||
)
|
||||
|
||||
if (( ${#changed_files[@]} == 0 )); then
|
||||
echo "No changed files supported by oxfmt."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bun run format:check -- "${changed_files[@]}"
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: ClawHub Rescan Guidance
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue:
|
||||
description: "Issue number to check"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: clawhub-rescan-guidance-${{ github.event.issue.number || github.event.inputs.issue }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rescan-guidance:
|
||||
runs-on: ubuntu-latest
|
||||
if: "${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'r: rescan-guidance' }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLAWHUB_RESCAN_GUIDANCE_APPLY: "1"
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Comment when rescan guidance label is present
|
||||
run: |
|
||||
node scripts/github/clawhub-rescan-auto-response.mjs \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--issue "$ISSUE_NUMBER" \
|
||||
--comment-for-labeled-issue \
|
||||
--apply
|
||||
@@ -94,7 +94,7 @@
|
||||
},
|
||||
"packages/clawhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { ConvexError } from "convex/values";
|
||||
import semver from "semver";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
parseFrontmatter,
|
||||
sanitizePath,
|
||||
} from "./skills";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
|
||||
import { generateSkillSummary } from "./skillSummary";
|
||||
import { runStaticPublishScan } from "./staticPublishScan";
|
||||
import type { WebhookSkillPayload } from "./webhooks";
|
||||
@@ -90,12 +91,16 @@ export async function publishVersionForUser(
|
||||
options: PublishOptions = {},
|
||||
): Promise<PublishResult> {
|
||||
const version = args.version.trim();
|
||||
const slug = args.slug.trim().toLowerCase();
|
||||
// Normalize first so we can look up the existing skill before deciding
|
||||
// how strictly to validate. The reserved-word blocklist and length floor
|
||||
// are only enforced for brand-new skills; owners of grandfathered slugs
|
||||
// (reserved, <3 chars, or >48 chars) must still be able to publish new
|
||||
// versions without being blocked by the write-path validator.
|
||||
const normalizedSlug = normalizeSkillSlug(args.slug);
|
||||
if (!normalizedSlug) throw new ConvexError("Slug is required.");
|
||||
|
||||
const displayName = args.displayName.trim();
|
||||
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
||||
throw new ConvexError("Slug must be lowercase and url-safe");
|
||||
}
|
||||
if (!displayName) throw new ConvexError("Display name required");
|
||||
if (!semver.valid(version)) {
|
||||
throw new ConvexError("Version must be valid semver");
|
||||
}
|
||||
@@ -104,10 +109,19 @@ export async function publishVersionForUser(
|
||||
await requireGitHubAccountAge(ctx, userId);
|
||||
}
|
||||
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
|
||||
slug,
|
||||
slug: normalizedSlug,
|
||||
})) as Doc<"skills"> | null;
|
||||
const isNewSkill = !existingSkill;
|
||||
|
||||
// For new skills, enforce the full write-path rules (length, pattern,
|
||||
// reserved-word blocklist). For existing skills the slug is already
|
||||
// persisted and grandfathered — re-validating it would block legitimate
|
||||
// version publishes on legacy rows.
|
||||
if (isNewSkill) {
|
||||
assertValidSkillSlug(normalizedSlug);
|
||||
}
|
||||
const slug = normalizedSlug;
|
||||
|
||||
const suppliedChangelog = args.changelog.trim();
|
||||
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertValidSkillSlug,
|
||||
isReservedSkillSlug,
|
||||
isSearchableSkillSlugShape,
|
||||
isValidSkillSlugShape,
|
||||
normalizeSkillSlug,
|
||||
normalizeSkillSlugOrNull,
|
||||
SKILL_SLUG_CONSTRAINTS,
|
||||
} from "./skillSlugValidator";
|
||||
|
||||
describe("normalizeSkillSlug", () => {
|
||||
it("trims and lowercases", () => {
|
||||
expect(normalizeSkillSlug(" Hello-World ")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("returns empty string for nullish", () => {
|
||||
expect(normalizeSkillSlug(undefined)).toBe("");
|
||||
expect(normalizeSkillSlug(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSkillSlugOrNull", () => {
|
||||
it("returns null for empty input", () => {
|
||||
expect(normalizeSkillSlugOrNull(" ")).toBeNull();
|
||||
expect(normalizeSkillSlugOrNull(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns normalized slug for non-empty input", () => {
|
||||
expect(normalizeSkillSlugOrNull(" MySkill ")).toBe("myskill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertValidSkillSlug", () => {
|
||||
it.each([
|
||||
"abc",
|
||||
"my-cool-skill",
|
||||
"skill-123",
|
||||
"a1b",
|
||||
"123",
|
||||
"abc-def-ghi",
|
||||
"z".repeat(SKILL_SLUG_CONSTRAINTS.maxLength),
|
||||
])("accepts valid slug %s", (slug) => {
|
||||
expect(() => assertValidSkillSlug(slug)).not.toThrow();
|
||||
expect(assertValidSkillSlug(slug)).toBe(slug.toLowerCase());
|
||||
});
|
||||
|
||||
it("normalizes mixed case and whitespace before validating", () => {
|
||||
expect(assertValidSkillSlug(" My-Cool-Skill ")).toBe("my-cool-skill");
|
||||
});
|
||||
|
||||
it("silently lowercases uppercase input (legacy-compatible)", () => {
|
||||
// Historically, write paths did `args.slug.trim().toLowerCase()` before
|
||||
// validating. We preserve that behaviour: uppercase input is normalized,
|
||||
// not rejected outright.
|
||||
expect(assertValidSkillSlug("A-B-C")).toBe("a-b-c");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "required"],
|
||||
[" ", "required"],
|
||||
["ab", "at least"],
|
||||
["a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1), "at most"],
|
||||
["-abc", "start and end"],
|
||||
["abc-", "start and end"],
|
||||
["a--b", "start and end"],
|
||||
["a---b", "start and end"],
|
||||
["a_b", "start and end"],
|
||||
["a.b", "start and end"],
|
||||
["a b", "start and end"],
|
||||
["a/b", "start and end"],
|
||||
])("rejects invalid slug %s", (slug, hint) => {
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow(new RegExp(hint, "i"));
|
||||
});
|
||||
|
||||
it.each(["admin", "settings", "api", "openclaw", "clawhub", "souls", "packages"])(
|
||||
"rejects reserved slug %s",
|
||||
(slug) => {
|
||||
// Some short reserved entries (e.g. "u") are also blocked by the
|
||||
// length rule; we only assert that a throw happens for every entry.
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("emits the reserved-specific error for long reserved slugs", () => {
|
||||
expect(() => assertValidSkillSlug("openclaw")).toThrow(/reserved/i);
|
||||
});
|
||||
|
||||
it("allows reserved slugs when allowReserved is set", () => {
|
||||
expect(() => assertValidSkillSlug("admin", { allowReserved: true })).not.toThrow();
|
||||
expect(assertValidSkillSlug("admin", { allowReserved: true })).toBe("admin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidSkillSlugShape", () => {
|
||||
it("returns true for well-formed slugs", () => {
|
||||
expect(isValidSkillSlugShape("abc")).toBe(true);
|
||||
expect(isValidSkillSlugShape("my-skill-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for reserved slugs (shape only)", () => {
|
||||
// The reserved-word blocklist is intentionally NOT consulted here so
|
||||
// that legacy rows carrying reserved slugs remain lookup-able.
|
||||
expect(isValidSkillSlugShape("admin")).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive (normalizes before checking)", () => {
|
||||
// Matches legacy read-path behaviour: search queries like "My-Skill"
|
||||
// should still resolve to the slug row.
|
||||
expect(isValidSkillSlugShape("A-B")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for malformed slugs", () => {
|
||||
expect(isValidSkillSlugShape("a")).toBe(false);
|
||||
expect(isValidSkillSlugShape("ab")).toBe(false);
|
||||
expect(isValidSkillSlugShape("a--b")).toBe(false);
|
||||
expect(isValidSkillSlugShape("-abc")).toBe(false);
|
||||
expect(isValidSkillSlugShape("abc-")).toBe(false);
|
||||
expect(isValidSkillSlugShape("a_b")).toBe(false);
|
||||
expect(isValidSkillSlugShape("")).toBe(false);
|
||||
expect(isValidSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReservedSkillSlug", () => {
|
||||
it("identifies reserved slugs case-insensitively", () => {
|
||||
expect(isReservedSkillSlug("admin")).toBe(true);
|
||||
expect(isReservedSkillSlug(" ADMIN ")).toBe(true);
|
||||
expect(isReservedSkillSlug("openclaw")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-reserved slugs", () => {
|
||||
expect(isReservedSkillSlug("my-skill")).toBe(false);
|
||||
expect(isReservedSkillSlug("")).toBe(false);
|
||||
expect(isReservedSkillSlug(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSearchableSkillSlugShape", () => {
|
||||
it("accepts slugs shorter than the write-path minimum (legacy rows)", () => {
|
||||
// Single- and two-character slugs predate the min-length floor but may
|
||||
// still exist in the skills table. Search must surface them via the
|
||||
// exact-slug fast path.
|
||||
expect(isSearchableSkillSlugShape("a")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("ab")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("a1")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts regular well-formed slugs", () => {
|
||||
expect(isSearchableSkillSlugShape("abc")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("my-skill-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts reserved slugs (read path ignores the blocklist)", () => {
|
||||
// Legacy data may still carry reserved slugs; they must remain
|
||||
// searchable even though the write path would now reject them.
|
||||
expect(isSearchableSkillSlugShape("admin")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("u")).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(isSearchableSkillSlugShape("A-B")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape(" My-Skill ")).toBe(true);
|
||||
});
|
||||
|
||||
it("still rejects malformed shapes", () => {
|
||||
expect(isSearchableSkillSlugShape("")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape(" ")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("-abc")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("abc-")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("a--b")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("a_b")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("a b")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("-")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts slugs longer than the write-path upper bound (legacy rows)", () => {
|
||||
// Legacy rows predate MAX_SLUG_LENGTH and may exceed 48 chars. The read
|
||||
// path must still resolve them via the by_slug fast path; otherwise
|
||||
// searchSkills falls back to scanning only the most recent digests and
|
||||
// can miss older records entirely.
|
||||
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength))).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("a".repeat(200))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
|
||||
// Slug shape rules:
|
||||
// - Lowercase letters, digits, and single hyphens only.
|
||||
// - Must start and end with a letter or digit.
|
||||
// - No consecutive hyphens ("--", "---", ...).
|
||||
// - Length 3..48 (URL/SEO friendly, aligned with publisher handle).
|
||||
//
|
||||
// The pattern enforces first/last char class and forbids consecutive hyphens
|
||||
// via a negative lookahead. Length bounds are checked separately so we can
|
||||
// emit precise error messages.
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:(?!--)[a-z0-9-])*[a-z0-9]$/;
|
||||
|
||||
const MIN_SLUG_LENGTH = 3;
|
||||
const MAX_SLUG_LENGTH = 48;
|
||||
|
||||
// Reserved slugs. These are blocked because they would:
|
||||
// 1. Clash semantically with top-level routes under src/routes/*.
|
||||
// 2. Allow brand/role impersonation (e.g. "official", "clawhub").
|
||||
// 3. Lock future route expansion (e.g. "api", "auth", "oauth").
|
||||
//
|
||||
// Keep this list in sync with:
|
||||
// - src/routes/*.tsx top-level segments
|
||||
// - brand names shipped in README.md
|
||||
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
|
||||
// Current top-level route segments under src/routes/.
|
||||
"about",
|
||||
"admin",
|
||||
"cli",
|
||||
"dashboard",
|
||||
"import",
|
||||
"management",
|
||||
"orgs",
|
||||
"packages",
|
||||
"plugins",
|
||||
"publish",
|
||||
"publish-plugin",
|
||||
"publish-skill",
|
||||
"search",
|
||||
"settings",
|
||||
"skills",
|
||||
"souls",
|
||||
"stars",
|
||||
"u",
|
||||
"upload",
|
||||
"users",
|
||||
// Reserved for likely future additions.
|
||||
"api",
|
||||
"auth",
|
||||
"oauth",
|
||||
"callback",
|
||||
"login",
|
||||
"logout",
|
||||
"signin",
|
||||
"signout",
|
||||
"signup",
|
||||
"register",
|
||||
"docs",
|
||||
"doc",
|
||||
"help",
|
||||
"support",
|
||||
"status",
|
||||
"health",
|
||||
"blog",
|
||||
"news",
|
||||
"pricing",
|
||||
"terms",
|
||||
"privacy",
|
||||
"legal",
|
||||
"contact",
|
||||
"home",
|
||||
"explore",
|
||||
// Brand and project names.
|
||||
"openclaw",
|
||||
"clawhub",
|
||||
"clawd",
|
||||
"clawdbot",
|
||||
"onlycrabs",
|
||||
"soulhub",
|
||||
// Generic identity / role words.
|
||||
"me",
|
||||
"self",
|
||||
"system",
|
||||
"root",
|
||||
"owner",
|
||||
"official",
|
||||
"staff",
|
||||
"team",
|
||||
"mod",
|
||||
"moderator",
|
||||
// Reserved CRUD/action words that would make URLs ambiguous.
|
||||
"new",
|
||||
"edit",
|
||||
"delete",
|
||||
"create",
|
||||
"update",
|
||||
"remove",
|
||||
"public",
|
||||
"private",
|
||||
"internal",
|
||||
// Literals that would be confusing in URLs.
|
||||
"null",
|
||||
"undefined",
|
||||
"true",
|
||||
"false",
|
||||
]);
|
||||
|
||||
export interface ValidateSlugOptions {
|
||||
/**
|
||||
* Bypass the reserved-word blocklist.
|
||||
* Intended for admin migrations / internal seeding only.
|
||||
*/
|
||||
allowReserved?: boolean;
|
||||
}
|
||||
|
||||
export const SKILL_SLUG_CONSTRAINTS = {
|
||||
minLength: MIN_SLUG_LENGTH,
|
||||
maxLength: MAX_SLUG_LENGTH,
|
||||
pattern: SLUG_PATTERN,
|
||||
reserved: RESERVED_SKILL_SLUGS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Lowercase and trim a slug. Does not throw.
|
||||
*
|
||||
* Safe to call on any read-path input (query by slug, redirect lookup, ...)
|
||||
* without rejecting legacy data.
|
||||
*/
|
||||
export function normalizeSkillSlug(raw: string | undefined | null): string {
|
||||
return (raw ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant that returns null when the input normalizes to an empty string.
|
||||
* Useful at read-path call sites that want to short-circuit lookup.
|
||||
*/
|
||||
export function normalizeSkillSlugOrNull(raw: string | undefined | null): string | null {
|
||||
const normalized = normalizeSkillSlug(raw);
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a string already matches the full slug shape rules.
|
||||
* Returns true only when the value is a plausible slug (length, pattern).
|
||||
*
|
||||
* Note: this intentionally does NOT consult the reserved-word blocklist
|
||||
* because legacy rows may still carry reserved slugs and we want to
|
||||
* keep them readable. It DOES enforce the current min-length floor
|
||||
* (MIN_SLUG_LENGTH) and is therefore only appropriate for call sites
|
||||
* that treat a value as a "newly-shaped" slug. For read-only lookups
|
||||
* (search, redirect) that must stay discoverable for pre-existing
|
||||
* short slugs, use isSearchableSkillSlugShape instead.
|
||||
*/
|
||||
export function isValidSkillSlugShape(value: string | undefined | null): boolean {
|
||||
const normalized = normalizeSkillSlug(value);
|
||||
if (normalized.length < MIN_SLUG_LENGTH || normalized.length > MAX_SLUG_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
return SLUG_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient shape check used by read paths (search exact-slug optimization,
|
||||
* redirect lookups, etc.).
|
||||
*
|
||||
* Unlike isValidSkillSlugShape, this predicate intentionally omits:
|
||||
* - the min-length floor (legacy rows with 1- or 2-char slugs must stay
|
||||
* retrievable via the by_slug fast path),
|
||||
* - the max-length cap (rows persisted before MAX_SLUG_LENGTH was
|
||||
* introduced may exceed 48 chars and must remain lookup-able; the
|
||||
* indexed point lookup for a missing key is cheap, and upstream
|
||||
* request-body limits bound the practical query length),
|
||||
* - the reserved-word blocklist (grandfathered data must stay readable).
|
||||
*
|
||||
* Write paths MUST continue to use assertValidSkillSlug, which enforces
|
||||
* the full validation surface (length floor + length cap + pattern +
|
||||
* reserved blocklist).
|
||||
*/
|
||||
export function isSearchableSkillSlugShape(value: string | undefined | null): boolean {
|
||||
const normalized = normalizeSkillSlug(value);
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Single-character legacy slug: a bare [a-z0-9] is searchable. The full
|
||||
// SLUG_PATTERN requires >=2 chars (separate first/last classes), so we
|
||||
// handle the single-char case explicitly before delegating to it.
|
||||
if (normalized.length === 1) {
|
||||
return /^[a-z0-9]$/.test(normalized);
|
||||
}
|
||||
return SLUG_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a normalized slug or throws ConvexError describing the first
|
||||
* violation encountered. Use this on every write path (publish/rename).
|
||||
*/
|
||||
export function assertValidSkillSlug(
|
||||
rawSlug: string | undefined | null,
|
||||
options: ValidateSlugOptions = {},
|
||||
): string {
|
||||
const normalized = normalizeSkillSlug(rawSlug);
|
||||
|
||||
if (!normalized) {
|
||||
throw new ConvexError("Slug is required.");
|
||||
}
|
||||
if (normalized.length < MIN_SLUG_LENGTH) {
|
||||
throw new ConvexError(`Slug must be at least ${MIN_SLUG_LENGTH} characters.`);
|
||||
}
|
||||
if (normalized.length > MAX_SLUG_LENGTH) {
|
||||
throw new ConvexError(`Slug must be at most ${MAX_SLUG_LENGTH} characters.`);
|
||||
}
|
||||
if (!SLUG_PATTERN.test(normalized)) {
|
||||
throw new ConvexError(
|
||||
"Slug must start and end with a letter or digit, contain only lowercase letters, " +
|
||||
"digits, and single hyphens, and not contain consecutive hyphens.",
|
||||
);
|
||||
}
|
||||
if (!options.allowReserved && RESERVED_SKILL_SLUGS.has(normalized)) {
|
||||
throw new ConvexError(`"${normalized}" is reserved and cannot be used as a slug.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience predicate: is the slug on the reserved blocklist?
|
||||
* Exposed so callers (e.g. admin tooling) can pre-check without a throw.
|
||||
*/
|
||||
export function isReservedSkillSlug(slug: string | undefined | null): boolean {
|
||||
const normalized = normalizeSkillSlug(slug);
|
||||
return RESERVED_SKILL_SLUGS.has(normalized);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { ConvexError } from "convex/values";
|
||||
import semver from "semver";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseFrontmatter,
|
||||
sanitizePath,
|
||||
} from "./skills";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
|
||||
import { generateSoulChangelogForPublish } from "./soulChangelog";
|
||||
|
||||
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
@@ -84,18 +85,31 @@ export async function publishSoulVersionForUser(
|
||||
args: PublishVersionArgs,
|
||||
): Promise<PublishResult> {
|
||||
const version = args.version.trim();
|
||||
const slug = args.slug.trim().toLowerCase();
|
||||
// Normalize first so we can look up the existing soul before deciding how
|
||||
// strictly to validate. Owners of grandfathered slugs (reserved, <3 chars,
|
||||
// or >48 chars) must still be able to publish new versions; the strict
|
||||
// write-path rules only apply when creating a brand-new soul.
|
||||
const normalizedSlug = normalizeSkillSlug(args.slug);
|
||||
if (!normalizedSlug) throw new ConvexError("Slug is required.");
|
||||
|
||||
const displayName = args.displayName.trim();
|
||||
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
||||
throw new ConvexError("Slug must be lowercase and url-safe");
|
||||
}
|
||||
if (!displayName) throw new ConvexError("Display name required");
|
||||
if (!semver.valid(version)) {
|
||||
throw new ConvexError("Version must be valid semver");
|
||||
}
|
||||
|
||||
await requireGitHubAccountAge(ctx, userId);
|
||||
|
||||
// Resolve existing soul before enforcing slug rules so grandfathered rows
|
||||
// are not blocked. Full validation is only applied on the create path.
|
||||
const existingSoul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
|
||||
slug: normalizedSlug,
|
||||
})) as Doc<"souls"> | null;
|
||||
if (!existingSoul) {
|
||||
assertValidSkillSlug(normalizedSlug);
|
||||
}
|
||||
const slug = normalizedSlug;
|
||||
|
||||
const suppliedChangelog = args.changelog.trim();
|
||||
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ const insertReleaseInternalHandler = (
|
||||
capabilities?: unknown;
|
||||
verification?: unknown;
|
||||
staticScan?: unknown;
|
||||
allowExistingRelease?: boolean;
|
||||
extractedPackageJson?: unknown;
|
||||
extractedPluginManifest?: unknown;
|
||||
normalizedBundleManifest?: unknown;
|
||||
@@ -577,16 +578,40 @@ function makeInsertReleaseCtx(
|
||||
}
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
if (indexName === "by_package") {
|
||||
return {
|
||||
collect: vi.fn().mockResolvedValue(priorReleases),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_package_version") {
|
||||
const filters = new Map<string, unknown>();
|
||||
const query = {
|
||||
eq(field: string, value: unknown) {
|
||||
filters.set(field, value);
|
||||
return query;
|
||||
},
|
||||
};
|
||||
buildQuery?.(query);
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(
|
||||
priorReleases.find(
|
||||
(release) =>
|
||||
release.packageId === filters.get("packageId") &&
|
||||
release.version === filters.get("version"),
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
@@ -2011,6 +2036,66 @@ describe("packages public queries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate package versions by default", async () => {
|
||||
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
|
||||
makeReleaseDoc({
|
||||
_id: "packageReleases:existing",
|
||||
version: "1.0.0",
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
insertReleaseInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
ownerUserId: "users:owner",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "retry",
|
||||
tags: ["latest"],
|
||||
summary: "demo",
|
||||
files: [],
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
).rejects.toThrow("Version 1.0.0 already exists");
|
||||
});
|
||||
|
||||
it("treats matching workflow duplicate package releases as idempotent", async () => {
|
||||
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
|
||||
makeReleaseDoc({
|
||||
_id: "packageReleases:existing",
|
||||
version: "1.0.0",
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
insertReleaseInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
ownerUserId: "users:owner",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "retry",
|
||||
tags: ["latest"],
|
||||
summary: "demo",
|
||||
files: [],
|
||||
integritySha256: "abc123",
|
||||
allowExistingRelease: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:existing",
|
||||
});
|
||||
|
||||
expect(ctx.insert).not.toHaveBeenCalled();
|
||||
expect(ctx.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a latest tag when an untagged promoted release becomes the package latest", async () => {
|
||||
const ctx = makeInsertReleaseCtx(
|
||||
makePackageDoc({
|
||||
|
||||
+18
-1
@@ -2005,6 +2005,9 @@ async function publishPackageImpl(
|
||||
staticScan,
|
||||
files,
|
||||
integritySha256,
|
||||
allowExistingRelease:
|
||||
auth.kind === "github-actions" ||
|
||||
(auth.kind === "user" && manualOverrideReason?.startsWith("GitHub Actions ")),
|
||||
extractedPackageJson: packageJson,
|
||||
extractedPluginManifest:
|
||||
family === "code-plugin" ? maybeParseJson(pluginManifestEntry?.text) : undefined,
|
||||
@@ -2164,6 +2167,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
capabilities: v.optional(v.any()),
|
||||
verification: v.optional(v.any()),
|
||||
staticScan: v.optional(v.any()),
|
||||
allowExistingRelease: v.optional(v.boolean()),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
@@ -2285,7 +2289,20 @@ export const insertReleaseInternal = internalMutation({
|
||||
q.eq("packageId", existing._id).eq("version", args.version),
|
||||
)
|
||||
.unique();
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
if (releaseExists) {
|
||||
if (
|
||||
args.allowExistingRelease &&
|
||||
!releaseExists.softDeletedAt &&
|
||||
releaseExists.integritySha256 === args.integritySha256
|
||||
) {
|
||||
return {
|
||||
ok: true as const,
|
||||
packageId: existing._id,
|
||||
releaseId: releaseExists._id,
|
||||
};
|
||||
}
|
||||
throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
}
|
||||
}
|
||||
const priorReleases = existing
|
||||
? await ctx.db
|
||||
|
||||
+12
-5
@@ -12,6 +12,7 @@ import { matchesExactTokens, tokenize } from "./lib/searchText";
|
||||
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
|
||||
import { isSkillSuspicious } from "./lib/skillSafety";
|
||||
import { digestToHydratableSkill, digestToOwnerInfo } from "./lib/skillSearchDigest";
|
||||
import { isSearchableSkillSlugShape, normalizeSkillSlug } from "./lib/skillSlugValidator";
|
||||
|
||||
type OwnerInfo = { ownerHandle: string | null; owner: PublicPublisher | null };
|
||||
|
||||
@@ -123,7 +124,11 @@ function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearch
|
||||
}
|
||||
|
||||
function isSlugLikeQuery(query: string) {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
|
||||
// Lenient shape check used by the read path: pattern + upper length cap only.
|
||||
// The min-length floor and reserved-word blocklist are intentionally omitted
|
||||
// so legacy rows (grandfathered short/reserved slugs) remain discoverable via
|
||||
// the exact-slug fast path. Write paths still go through assertValidSkillSlug.
|
||||
return isSearchableSkillSlugShape(query);
|
||||
}
|
||||
|
||||
function matchesCapabilityTag(
|
||||
@@ -183,8 +188,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
const results = await ctx.vectorSearch("skillEmbeddings", "by_embedding", {
|
||||
vector,
|
||||
limit: candidateLimit,
|
||||
filter: (q) =>
|
||||
q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
|
||||
filter: (q) => q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
|
||||
});
|
||||
|
||||
// Only hydrate embedding IDs we haven't seen yet (incremental).
|
||||
@@ -371,8 +375,11 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
>();
|
||||
|
||||
// Exact slug match via the skills table (only one row, cheap).
|
||||
const slugQuery = args.query.trim().toLowerCase();
|
||||
if (!args.skipExactSlugLookup && /^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
// Use the lenient shape predicate so legacy rows with sub-min-length
|
||||
// slugs stay discoverable; the caller in searchSkills already passes
|
||||
// skipExactSlugLookup=true after running its own exact-slug lookup.
|
||||
const slugQuery = normalizeSkillSlug(args.query);
|
||||
if (!args.skipExactSlugLookup && isSearchableSkillSlugShape(slugQuery)) {
|
||||
const exactSlugSkill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
|
||||
|
||||
+28
-14
@@ -96,6 +96,7 @@ import {
|
||||
extractDigestFields,
|
||||
upsertSkillSearchDigest,
|
||||
} from "./lib/skillSearchDigest";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
|
||||
import { readCanonicalStat } from "./lib/skillStats";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
@@ -551,7 +552,10 @@ function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef)
|
||||
}
|
||||
|
||||
function normalizeSkillSlugKey(slug: string) {
|
||||
return slug.trim().toLowerCase();
|
||||
// Read-path normalization: lowercase + trim only. Intentionally lenient so
|
||||
// that legacy rows (pre-validator) remain lookup-able. Write paths must
|
||||
// use `normalizeSkillSlugForWrite` / `assertValidSkillSlug` instead.
|
||||
return normalizeSkillSlug(slug);
|
||||
}
|
||||
|
||||
type SkillOwnerRef =
|
||||
@@ -565,11 +569,9 @@ type SkillOwnerRef =
|
||||
| undefined;
|
||||
|
||||
function normalizeSkillSlugForWrite(slug: string) {
|
||||
const normalized = normalizeSkillSlugKey(slug);
|
||||
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
|
||||
throw new ConvexError("Slug must be lowercase and url-safe");
|
||||
}
|
||||
return normalized;
|
||||
// Write-path: full validation (length, pattern, reserved words,
|
||||
// no consecutive hyphens). See `lib/skillSlugValidator.ts`.
|
||||
return assertValidSkillSlug(slug);
|
||||
}
|
||||
|
||||
async function getSkillSlugAliasBySlug(ctx: Pick<QueryCtx | MutationCtx, "db">, slug: string) {
|
||||
@@ -5789,13 +5791,11 @@ async function renameOwnedSkillByActor(
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const sourceSlug = sourceSlugArg.trim().toLowerCase();
|
||||
const newSlug = newSlugArg.trim().toLowerCase();
|
||||
const sourceSlug = normalizeSkillSlug(sourceSlugArg);
|
||||
if (!sourceSlug) throw new ConvexError("Current slug required");
|
||||
if (!newSlug) throw new ConvexError("New slug required");
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(newSlug)) {
|
||||
throw new ConvexError("Invalid slug. Use lowercase letters, numbers, and hyphens only.");
|
||||
}
|
||||
// Full write-path validation for the new slug: length, pattern,
|
||||
// reserved-word blocklist, no consecutive hyphens.
|
||||
const newSlug = assertValidSkillSlug(newSlugArg);
|
||||
|
||||
const resolved = await resolveSkillBySlugOrAlias(ctx, sourceSlug);
|
||||
const skill = resolved.skill;
|
||||
@@ -6530,7 +6530,16 @@ export const insertVersion = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId;
|
||||
const slug = normalizeSkillSlugForWrite(args.slug);
|
||||
// Lenient normalization first so we can look up an existing skill row
|
||||
// before deciding whether to enforce the strict write-path validator.
|
||||
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
|
||||
// pre-validator shapes) must remain able to publish new versions; the
|
||||
// strict reserved/length/pattern rules only apply when creating a brand
|
||||
// new skill. The caller (publishVersionForUser) performs the same split,
|
||||
// but the mutation re-validates defensively because it can be invoked on
|
||||
// its own (e.g. tests, internal schedulers).
|
||||
const normalizedSlug = normalizeSkillSlug(args.slug);
|
||||
if (!normalizedSlug) throw new ConvexError("Slug is required.");
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
||||
const personalPublisher = await ensurePersonalPublisherForUser(ctx, user);
|
||||
@@ -6548,9 +6557,14 @@ export const insertVersion = internalMutation({
|
||||
|
||||
let skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
|
||||
.unique();
|
||||
|
||||
// Only enforce the strict write-path rules when creating a new skill.
|
||||
// For existing rows, keep the already-persisted (possibly grandfathered)
|
||||
// slug as-is so legacy publishers are not locked out of version updates.
|
||||
const slug = skill ? normalizedSlug : normalizeSkillSlugForWrite(args.slug);
|
||||
|
||||
if (!skill) {
|
||||
const alias = await getSkillSlugAliasBySlug(ctx, slug);
|
||||
if (alias) {
|
||||
|
||||
+21
-8
@@ -7,6 +7,7 @@ import { assertModerator, requireUser, requireUserFromAction } from "./lib/acces
|
||||
import { embeddingVisibilityFor } from "./lib/embeddingVisibility";
|
||||
import { toPublicSoul, toPublicUser } from "./lib/public";
|
||||
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
|
||||
import { generateSoulChangelogPreview } from "./lib/soulChangelog";
|
||||
import { fetchText, type PublishResult, publishSoulVersionForUser } from "./lib/soulPublish";
|
||||
|
||||
@@ -70,15 +71,15 @@ function toPublicSoulVersion(
|
||||
}
|
||||
|
||||
function normalizeSoulSlugKey(slug: string) {
|
||||
return slug.trim().toLowerCase();
|
||||
// Read-path normalization: lowercase + trim only. Intentionally lenient so
|
||||
// that legacy rows (pre-validator) remain lookup-able.
|
||||
return normalizeSkillSlug(slug);
|
||||
}
|
||||
|
||||
function normalizeSoulSlugForWrite(slug: string) {
|
||||
const normalized = normalizeSoulSlugKey(slug);
|
||||
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
|
||||
throw new ConvexError("Slug must be lowercase and url-safe");
|
||||
}
|
||||
return normalized;
|
||||
// Write-path: full validation (length, pattern, reserved words,
|
||||
// no consecutive hyphens). Souls share the rules with skills.
|
||||
return assertValidSkillSlug(slug);
|
||||
}
|
||||
|
||||
export const getBySlug = query({
|
||||
@@ -497,17 +498,29 @@ export const insertVersion = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId;
|
||||
const slug = normalizeSoulSlugForWrite(args.slug);
|
||||
// Lenient normalization first: we must look up the existing soul row
|
||||
// before deciding whether to enforce the strict write-path validator.
|
||||
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
|
||||
// pre-validator shapes) must remain able to publish new versions; the
|
||||
// strict rules only apply when creating a brand new soul. The caller
|
||||
// (publishSoulVersionForUser) performs the same split, but the mutation
|
||||
// re-validates defensively because it can be invoked on its own.
|
||||
const normalizedSlug = normalizeSkillSlug(args.slug);
|
||||
if (!normalizedSlug) throw new ConvexError("Slug is required.");
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
||||
|
||||
const soulMatches = await ctx.db
|
||||
.query("souls")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
|
||||
.order("desc")
|
||||
.take(2);
|
||||
let soul: Doc<"souls"> | null = soulMatches[0] ?? null;
|
||||
|
||||
// Only enforce the strict write-path rules when creating a new soul; for
|
||||
// existing rows keep the already-persisted (possibly grandfathered) slug.
|
||||
const slug = soul ? normalizedSlug : normalizeSoulSlugForWrite(args.slug);
|
||||
|
||||
if (soul && soul.ownerUserId !== userId) {
|
||||
throw new ConvexError("Only the owner can publish soul updates");
|
||||
}
|
||||
|
||||
+1
-2
@@ -394,12 +394,11 @@ export const list = query({
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 40, 1, 100);
|
||||
const result = await queryUsersForPublicList(ctx, {
|
||||
limit,
|
||||
search: args.search,
|
||||
});
|
||||
return {
|
||||
items: result.items
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dev": "bun --bun vite dev --port 3000",
|
||||
"docs:list": "bun scripts/docs-list.ts",
|
||||
"format": "oxfmt --write",
|
||||
"format:check": "oxfmt --check",
|
||||
"install:local-hooks": "bun scripts/install-git-hooks.mjs",
|
||||
"lint": "bun run lint:oxlint",
|
||||
"lint:fix": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src --fix && bun run format",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
|
||||
"homepage": "https://clawhub.ai",
|
||||
"bugs": {
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const RESCAN_GUIDANCE_LABEL = "r: rescan-guidance";
|
||||
export const RESCAN_GUIDANCE_COMMENT_MARKER = "<!-- clawhub-rescan-guidance -->";
|
||||
export const SUPPRESS_LABEL = "skip-rescan-guidance";
|
||||
|
||||
const DEFAULT_REPO = "openclaw/clawhub";
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const APPLY_CONFIRM_ENV = "CLAWHUB_RESCAN_GUIDANCE_APPLY";
|
||||
|
||||
const explicitIntentRules = [
|
||||
{
|
||||
id: "explicit-rescan",
|
||||
pattern:
|
||||
/\b(?:re[-\s]?scan|rerun(?:ning)?\s+(?:the\s+)?(?:security\s+)?scan|re-run\s+(?:the\s+)?(?:security\s+)?scan|run\s+(?:the\s+)?(?:security\s+)?scan\s+again|scan\s+again)\b/i,
|
||||
},
|
||||
{
|
||||
id: "re-evaluation",
|
||||
pattern: /\b(?:re[-\s]?evaluat(?:e|ion)|reassess|re-assess|re[-\s]?review)\b/i,
|
||||
},
|
||||
{
|
||||
id: "reclassification",
|
||||
pattern:
|
||||
/\b(?:re[-\s]?classif(?:y|ication)|remove\s+(?:the\s+)?suspicious\s+flag|clear\s+(?:the\s+)?suspicious\s+flag|mark\s+(?:it\s+)?(?:as\s+)?(?:clean|benign))\b/i,
|
||||
},
|
||||
{
|
||||
id: "review-after-fix",
|
||||
pattern:
|
||||
/\b(?:security\s+flag\s+review|scan\s+flag\s+review|(?:request(?:ing)?|please)\s+(?:a\s+)?(?:manual\s+)?(?:review|security\s+review)\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version)|review\s+request\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version))\b/i,
|
||||
},
|
||||
{
|
||||
id: "fixed-and-still-flagged",
|
||||
pattern:
|
||||
/\b(?:(?:after|despite)\s+(?:fixing|fixes|metadata\s+fixes|clarifying|removing)|fix(?:ed|es)?\s+.*\b(?:still|yet)\s+.*\b(?:flagged|suspicious))\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
const moderationContextRules = [
|
||||
{
|
||||
id: "clawhub-asset",
|
||||
pattern: /\b(?:skill|plugin|package|publisher|published|version|clawhub)\b/i,
|
||||
},
|
||||
{
|
||||
id: "moderation-signal",
|
||||
pattern:
|
||||
/\b(?:suspicious|flagged\s+(?:as\s+)?suspicious|security\s+scan|scanner|virustotal|vt\b|openclaw\s+verdict|moderation|malicious|benign|clean)\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
const negativeContextRules = [
|
||||
{
|
||||
id: "auth-login",
|
||||
pattern: /\b(?:login|log\s+in|sign[-\s]?in|oauth|unauthorized|token|callback)\b/i,
|
||||
},
|
||||
{
|
||||
id: "install-rate-limit",
|
||||
pattern: /\b(?:install(?:ing)?|rate\s+limit|429|download|npx)\b/i,
|
||||
},
|
||||
{
|
||||
id: "search-indexing",
|
||||
pattern: /\b(?:search|indexed|indexing|explore|catalog|disappeared|hidden)\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
export const rescanGuidanceComment = [
|
||||
RESCAN_GUIDANCE_COMMENT_MARKER,
|
||||
'Thanks for the report. Please use the "Rescan" button on the skill/plugin page while signed in as the owner.',
|
||||
"",
|
||||
"You can also request a fresh scan from the CLI:",
|
||||
"- Skill: `clawhub skill rescan <slug>`",
|
||||
"- Plugin/package: `clawhub package rescan <name>`",
|
||||
"",
|
||||
"If the content or metadata changed, publish the fixed version first, then request the rescan for the latest release. I'm closing this issue after posting this guidance. If you're still having trouble after rescanning, please reopen this issue with the ClawHub URL, version, and latest scan result.",
|
||||
].join("\n");
|
||||
|
||||
function normalizeLabel(label) {
|
||||
if (typeof label === "string") return label.trim().toLowerCase();
|
||||
if (label && typeof label.name === "string") return label.name.trim().toLowerCase();
|
||||
return "";
|
||||
}
|
||||
|
||||
function issueLabels(issue) {
|
||||
return Array.isArray(issue.labels) ? issue.labels.map(normalizeLabel).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function issueState(issue) {
|
||||
return String(issue.state ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function issueText(issue) {
|
||||
return `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
|
||||
}
|
||||
|
||||
function matchingRuleIds(rules, text) {
|
||||
return rules.filter((rule) => rule.pattern.test(text)).map((rule) => rule.id);
|
||||
}
|
||||
|
||||
function commentHash(body) {
|
||||
return createHash("sha256").update(body).digest("hex");
|
||||
}
|
||||
|
||||
export function classifyRescanRequest(issue) {
|
||||
const labels = issueLabels(issue);
|
||||
const state = issueState(issue);
|
||||
const text = issueText(issue);
|
||||
|
||||
if (state && state !== "OPEN") {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.pull_request || issue.isPullRequest) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "Skipped because this is a pull request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (labels.includes(SUPPRESS_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${SUPPRESS_LABEL} is present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (labels.includes(RESCAN_GUIDANCE_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is already present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(?:false\s+duplicate|duplicate\s+flag|not\s+a\s+duplicate|duplicate\s+of)\b/i.test(text)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason:
|
||||
"Skipped because this looks like a duplicate-classification appeal, not a rescan request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const explicitMatches = matchingRuleIds(explicitIntentRules, text);
|
||||
if (explicitMatches.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "No explicit rescan, re-evaluation, review, or reclassification request found.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const contextMatches = matchingRuleIds(moderationContextRules, text);
|
||||
if (contextMatches.length < moderationContextRules.length) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: explicitMatches,
|
||||
reason: "Explicit request found, but it lacks ClawHub asset and moderation/scan context.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const negativeMatches = matchingRuleIds(negativeContextRules, text);
|
||||
const hasStrongModerationLanguage =
|
||||
/\b(?:suspicious|flagged|virustotal|vt\b|malicious|benign|clean|security\s+scan|scanner|moderation)\b/i.test(
|
||||
text,
|
||||
);
|
||||
if (negativeMatches.length > 0 && !hasStrongModerationLanguage) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [...explicitMatches, ...contextMatches],
|
||||
reason: `Skipped because it looks like ${negativeMatches.join(", ")} support rather than a moderation rescan request.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const matchedRules = [...explicitMatches, ...contextMatches];
|
||||
return {
|
||||
matched: true,
|
||||
matchedRules,
|
||||
reason: `Explicit rescan guidance match: ${matchedRules.join(", ")}.`,
|
||||
actions: planRescanGuidanceActions(),
|
||||
};
|
||||
}
|
||||
|
||||
export function planRescanGuidanceActions() {
|
||||
return [
|
||||
{
|
||||
type: "add_label",
|
||||
label: RESCAN_GUIDANCE_LABEL,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function planCommentForLabeledIssue(issue) {
|
||||
const labels = issueLabels(issue);
|
||||
const state = issueState(issue);
|
||||
|
||||
if (state && state !== "OPEN") {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.pull_request || issue.isPullRequest) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "Skipped because this is a pull request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!labels.includes(RESCAN_GUIDANCE_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is not present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
matchedRules: ["rescan-guidance-label"],
|
||||
reason: `Matched because ${RESCAN_GUIDANCE_LABEL} is present.`,
|
||||
actions: [
|
||||
{
|
||||
type: "comment",
|
||||
body: rescanGuidanceComment,
|
||||
bodySha256: commentHash(rescanGuidanceComment),
|
||||
},
|
||||
{
|
||||
type: "close",
|
||||
stateReason: "not_planned",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
repo: DEFAULT_REPO,
|
||||
limit: DEFAULT_LIMIT,
|
||||
issues: [],
|
||||
dryRun: true,
|
||||
json: true,
|
||||
commentForLabeledIssue: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--repo") {
|
||||
args.repo = requireValue(argv, (index += 1), "--repo");
|
||||
} else if (arg === "--limit") {
|
||||
args.limit = Number.parseInt(requireValue(argv, (index += 1), "--limit"), 10);
|
||||
} else if (arg === "--issue" || arg === "--item") {
|
||||
args.issues.push(Number.parseInt(requireValue(argv, (index += 1), arg), 10));
|
||||
} else if (arg === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
} else if (arg === "--apply") {
|
||||
args.dryRun = false;
|
||||
} else if (arg === "--comment-for-labeled-issue") {
|
||||
args.commentForLabeledIssue = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isInteger(args.limit) || args.limit < 1) {
|
||||
throw new Error("--limit must be a positive integer.");
|
||||
}
|
||||
if (args.issues.some((issue) => !Number.isInteger(issue) || issue < 1)) {
|
||||
throw new Error("--issue values must be positive integers.");
|
||||
}
|
||||
if (args.commentForLabeledIssue && args.issues.length === 0) {
|
||||
throw new Error("--comment-for-labeled-issue requires --issue.");
|
||||
}
|
||||
if (!args.dryRun && process.env[APPLY_CONFIRM_ENV] !== "1") {
|
||||
throw new Error(`--apply requires ${APPLY_CONFIRM_ENV}=1.`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function requireValue(argv, index, flag) {
|
||||
const value = argv[index];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`${flag} requires a value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ghJson(args) {
|
||||
const stdout = execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function gh(args, input) {
|
||||
execFileSync("gh", args, {
|
||||
encoding: "utf8",
|
||||
input,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ghOk(args) {
|
||||
try {
|
||||
gh(args);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function labelApiName(label) {
|
||||
return encodeURIComponent(label);
|
||||
}
|
||||
|
||||
function ensureGuidanceLabel(repo) {
|
||||
if (ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) return;
|
||||
const created = ghOk([
|
||||
"label",
|
||||
"create",
|
||||
RESCAN_GUIDANCE_LABEL,
|
||||
"--repo",
|
||||
repo,
|
||||
"--color",
|
||||
"bfdadc",
|
||||
"--description",
|
||||
"Rescan guidance has been posted for this ClawHub item",
|
||||
]);
|
||||
if (!created && !ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) {
|
||||
throw new Error(`Could not create or find label: ${RESCAN_GUIDANCE_LABEL}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGhIssue(issue) {
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title ?? "",
|
||||
body: issue.body ?? "",
|
||||
state: issue.state ?? "",
|
||||
url: issue.url ?? issue.html_url ?? "",
|
||||
labels: issue.labels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchIssues(options) {
|
||||
if (options.issues.length > 0) {
|
||||
return options.issues.map((issueNumber) =>
|
||||
normalizeGhIssue(
|
||||
ghJson([
|
||||
"issue",
|
||||
"view",
|
||||
String(issueNumber),
|
||||
"--repo",
|
||||
options.repo,
|
||||
"--json",
|
||||
"number,title,body,state,url,labels",
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ghJson([
|
||||
"issue",
|
||||
"list",
|
||||
"--repo",
|
||||
options.repo,
|
||||
"--state",
|
||||
"open",
|
||||
"--limit",
|
||||
String(options.limit),
|
||||
"--json",
|
||||
"number,title,body,state,url,labels",
|
||||
]).map(normalizeGhIssue);
|
||||
}
|
||||
|
||||
export function planIssue(issue) {
|
||||
const classification = classifyRescanRequest(issue);
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
matched: classification.matched,
|
||||
matchedRules: classification.matchedRules,
|
||||
reason: classification.reason,
|
||||
actions: classification.actions,
|
||||
};
|
||||
}
|
||||
|
||||
function writeCommentPayload(plan) {
|
||||
const commentAction = plan.actions.find((action) => action.type === "comment");
|
||||
if (!commentAction) return null;
|
||||
return JSON.stringify({ body: commentAction.body });
|
||||
}
|
||||
|
||||
function hasExistingGuidanceComment(repo, number) {
|
||||
const comments = ghJson([
|
||||
"api",
|
||||
`repos/${repo}/issues/${number}/comments?per_page=100`,
|
||||
"--jq",
|
||||
`[.[] | {body}]`,
|
||||
]);
|
||||
return comments.some((comment) =>
|
||||
String(comment.body ?? "").includes(RESCAN_GUIDANCE_COMMENT_MARKER),
|
||||
);
|
||||
}
|
||||
|
||||
function applyPlan(plan, options) {
|
||||
if (!plan.matched) return { number: plan.number, applied: false, reason: plan.reason };
|
||||
if (plan.actions.some((action) => action.type === "add_label")) {
|
||||
ensureGuidanceLabel(options.repo);
|
||||
}
|
||||
const existingGuidanceComment = hasExistingGuidanceComment(options.repo, plan.number);
|
||||
const appliedActions = [];
|
||||
for (const action of plan.actions) {
|
||||
if (action.type === "add_label") {
|
||||
gh([
|
||||
"api",
|
||||
`repos/${options.repo}/issues/${plan.number}/labels`,
|
||||
"--method",
|
||||
"POST",
|
||||
"--field",
|
||||
`labels[]=${action.label}`,
|
||||
]);
|
||||
appliedActions.push(action.type);
|
||||
} else if (action.type === "comment") {
|
||||
if (existingGuidanceComment) continue;
|
||||
gh(
|
||||
[
|
||||
"api",
|
||||
`repos/${options.repo}/issues/${plan.number}/comments`,
|
||||
"--method",
|
||||
"POST",
|
||||
"--input",
|
||||
"-",
|
||||
],
|
||||
writeCommentPayload(plan),
|
||||
);
|
||||
appliedActions.push(action.type);
|
||||
} else if (action.type === "close") {
|
||||
gh(
|
||||
["api", `repos/${options.repo}/issues/${plan.number}`, "--method", "PATCH", "--input", "-"],
|
||||
JSON.stringify({ state: "closed", state_reason: action.stateReason ?? "not_planned" }),
|
||||
);
|
||||
appliedActions.push(action.type);
|
||||
}
|
||||
}
|
||||
return {
|
||||
number: plan.number,
|
||||
applied: appliedActions.length > 0,
|
||||
actions: appliedActions,
|
||||
skippedComment: existingGuidanceComment,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSummary(plans, options) {
|
||||
const matches = plans.filter((plan) => plan.matched);
|
||||
const lines = [
|
||||
`ClawHub rescan auto-response ${options.dryRun ? "dry run" : "apply run"} for ${options.repo}`,
|
||||
`Scanned ${plans.length} issue(s); matched ${matches.length}.`,
|
||||
];
|
||||
for (const plan of matches) {
|
||||
lines.push(`- #${plan.number}: ${plan.title}`);
|
||||
lines.push(` ${plan.url}`);
|
||||
lines.push(` rules: ${plan.matchedRules.join(", ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function helpText() {
|
||||
return [
|
||||
"Usage: bun scripts/github/clawhub-rescan-auto-response.mjs [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --repo <owner/repo> Repository to inspect. Default: openclaw/clawhub",
|
||||
" --limit <n> Number of open issues to scan. Default: 100",
|
||||
" --issue <n> Inspect one issue number. Repeatable.",
|
||||
" --dry-run Preview only. Default.",
|
||||
" --comment-for-labeled-issue",
|
||||
` Post guidance only when ${RESCAN_GUIDANCE_LABEL} is already present.`,
|
||||
` --apply Add the label and guidance comment. Requires ${APPLY_CONFIRM_ENV}=1.`,
|
||||
" --help Show this help.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function runCli(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
if (options.help) {
|
||||
console.log(helpText());
|
||||
return;
|
||||
}
|
||||
const issues = await fetchIssues(options);
|
||||
const plans = options.commentForLabeledIssue
|
||||
? issues.map((issue) => {
|
||||
const classification = planCommentForLabeledIssue(issue);
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
matched: classification.matched,
|
||||
matchedRules: classification.matchedRules,
|
||||
reason: classification.reason,
|
||||
actions: classification.actions,
|
||||
};
|
||||
})
|
||||
: issues.map(planIssue);
|
||||
const applyResults = options.dryRun ? [] : plans.map((plan) => applyPlan(plan, options));
|
||||
console.error(renderSummary(plans, options));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
repo: options.repo,
|
||||
dryRun: options.dryRun,
|
||||
scanned: plans.length,
|
||||
matched: plans.filter((plan) => plan.matched).length,
|
||||
applied: applyResults.filter((result) => result.applied).length,
|
||||
applyResults,
|
||||
plans,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === currentFile) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RESCAN_GUIDANCE_COMMENT_MARKER,
|
||||
RESCAN_GUIDANCE_LABEL,
|
||||
SUPPRESS_LABEL,
|
||||
classifyRescanRequest,
|
||||
planCommentForLabeledIssue,
|
||||
planIssue,
|
||||
rescanGuidanceComment,
|
||||
} from "./clawhub-rescan-auto-response.mjs";
|
||||
|
||||
const issue = (overrides) => ({
|
||||
number: 1,
|
||||
title: "placeholder",
|
||||
body: "",
|
||||
state: "OPEN",
|
||||
url: "https://github.com/openclaw/clawhub/issues/1",
|
||||
labels: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("clawhub rescan auto-response classifier", () => {
|
||||
it.each([
|
||||
[
|
||||
1553,
|
||||
"Re-scan jarviyin/clawpk v5.0.0 - remove suspicious flag",
|
||||
"Please re-run the security scan on v5.0.0 and remove the suspicious flag. The package no longer contains any patterns that should trigger it.",
|
||||
],
|
||||
[
|
||||
1834,
|
||||
"feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
|
||||
"The skill fixed credentials and Unicode control characters. Please re-run the security scan on v2.4.3.",
|
||||
],
|
||||
[
|
||||
1808,
|
||||
"Re-evaluation request: topview-skill (official Topview AI client) - medium-suspicious verdict triggered by emoji ZWJ false positive",
|
||||
"Please re-scan at the current commit and reclassify as Benign. The suspicious scan findings have been fixed.",
|
||||
],
|
||||
[
|
||||
1671,
|
||||
'Request for Security Re-evaluation: "book-companion" skill marked as suspicious',
|
||||
"I have proactively audited the skill and implemented compliance measures. Please review the updated documentation and clear the suspicious flag.",
|
||||
],
|
||||
])("matches explicit rescan/re-evaluation request #%s", (number, title, body) => {
|
||||
const result = classifyRescanRequest(issue({ number, title, body }));
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.matchedRules.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.actions).toEqual([{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }]);
|
||||
expect(rescanGuidanceComment).toContain(RESCAN_GUIDANCE_COMMENT_MARKER);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
589,
|
||||
"Rate limit exceeded when installing clawhub",
|
||||
"npx clawhub@latest install sonoscli returns Rate limit exceeded. Is this not getting fixed?",
|
||||
],
|
||||
[
|
||||
100,
|
||||
"CLI: Auth fails due to redirect from clawhub.ai to www.clawhub.ai",
|
||||
"The clawhub CLI fails to authenticate because a redirect loses the Authorization header.",
|
||||
],
|
||||
[
|
||||
758,
|
||||
"False positive: create-project skill flagged as suspicious by VirusTotal",
|
||||
"The create-project skill has been flagged as suspicious. This appears to be the same class of false positive as other issues.",
|
||||
],
|
||||
[
|
||||
256,
|
||||
"False positive: clawarr-suite flagged as suspicious",
|
||||
"Please review and unflag. All patterns are standard for a media server management tool.",
|
||||
],
|
||||
[
|
||||
1514,
|
||||
"False duplicate flag: claude-to-free is not a duplicate of model-migration",
|
||||
"This skill is not a duplicate. Please remove the duplicate flag.",
|
||||
],
|
||||
])("does not match non-rescan issue #%s", (number, title, body) => {
|
||||
const result = classifyRescanRequest(issue({ number, title, body }));
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips closed issues", () => {
|
||||
const result = classifyRescanRequest(
|
||||
issue({
|
||||
state: "CLOSED",
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.reason).toContain("closed");
|
||||
});
|
||||
|
||||
it("skips pull requests", () => {
|
||||
const result = classifyRescanRequest(
|
||||
issue({
|
||||
isPullRequest: true,
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.reason).toContain("pull request");
|
||||
});
|
||||
|
||||
it("skips suppressed and already-handled issues", () => {
|
||||
expect(
|
||||
classifyRescanRequest(
|
||||
issue({
|
||||
labels: [{ name: SUPPRESS_LABEL }],
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
).reason,
|
||||
).toContain(SUPPRESS_LABEL);
|
||||
|
||||
expect(
|
||||
classifyRescanRequest(
|
||||
issue({
|
||||
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
).reason,
|
||||
).toContain(RESCAN_GUIDANCE_LABEL);
|
||||
});
|
||||
|
||||
it("plans dry-run rows for matched issues", () => {
|
||||
const plan = planIssue(
|
||||
issue({
|
||||
number: 1834,
|
||||
title: "feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
|
||||
body: "This skill has fixed flagged metadata issues. Please re-run the security scan on v2.4.3.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
number: 1834,
|
||||
matched: true,
|
||||
actions: [{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }],
|
||||
});
|
||||
});
|
||||
|
||||
it("plans comments only for issues already labeled for guidance", () => {
|
||||
const plan = planCommentForLabeledIssue(
|
||||
issue({
|
||||
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
|
||||
title: "False positive: example skill flagged as suspicious",
|
||||
body: "Please re-run the security scan.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
matched: true,
|
||||
matchedRules: ["rescan-guidance-label"],
|
||||
actions: [
|
||||
{
|
||||
type: "comment",
|
||||
body: rescanGuidanceComment,
|
||||
bodySha256: expect.any(String),
|
||||
},
|
||||
{
|
||||
type: "close",
|
||||
stateReason: "not_planned",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(rescanGuidanceComment).toContain("reopen this issue");
|
||||
});
|
||||
|
||||
it("does not plan comments without the guidance label", () => {
|
||||
const plan = planCommentForLabeledIssue(
|
||||
issue({
|
||||
title: "False positive: example skill flagged as suspicious",
|
||||
body: "Please re-run the security scan.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
matched: false,
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+287
-133
@@ -1,198 +1,352 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Header from "../components/Header";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HeaderAuthStatus = {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
me: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const siteModeMock = vi.fn(() => "souls");
|
||||
const navigateMock = vi.fn();
|
||||
const { useUnifiedSearchMock } = vi.hoisted(() => ({
|
||||
useUnifiedSearchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultUnifiedSearchResult = {
|
||||
results: [],
|
||||
skillResults: [
|
||||
{
|
||||
type: "skill",
|
||||
ownerHandle: "local",
|
||||
score: 10,
|
||||
skill: {
|
||||
_id: "skills:weather",
|
||||
slug: "weather",
|
||||
displayName: "Weather Skill",
|
||||
ownerUserId: "users:local",
|
||||
stats: { downloads: 1, stars: 2 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
pluginResults: [
|
||||
{
|
||||
type: "plugin",
|
||||
plugin: {
|
||||
name: "weather-plugin",
|
||||
displayName: "Weather Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Plugin weather tools.",
|
||||
ownerHandle: "local",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
latestVersion: "1.0.0",
|
||||
capabilityTags: [],
|
||||
executesCode: true,
|
||||
verificationTier: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
skillCount: 1,
|
||||
pluginCount: 1,
|
||||
isSearching: false,
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: (props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hash?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a
|
||||
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
Link: (props: { children: ReactNode; className?: string; hash?: string; to?: string }) => (
|
||||
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const authStatusMock = vi.fn(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
const authStatusMock = vi.fn<() => HeaderAuthStatus>(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
}));
|
||||
|
||||
const setThemeMock = vi.fn();
|
||||
const setModeMock = vi.fn();
|
||||
|
||||
vi.mock("../lib/theme", () => ({
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/theme-transition", () => ({
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthError", () => ({
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/roles", () => ({
|
||||
isModerator: () => false,
|
||||
isModerator: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/gravatar", () => ({
|
||||
gravatarUrl: vi.fn(),
|
||||
gravatarUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => useUnifiedSearchMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/toggle-group", () => ({
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import Header from "../components/Header";
|
||||
|
||||
describe("Header", () => {
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
beforeEach(() => {
|
||||
authStatusMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
});
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult);
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
render(<Header />);
|
||||
|
||||
it("renders simplified desktop nav and theme toggle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("renders simplified desktop nav and theme toggle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).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();
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
|
||||
);
|
||||
expect(setModeMock).toHaveBeenCalledWith("dark");
|
||||
expect(screen.getByRole("button", { name: /Toggle theme\. Current: system/i })).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).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 and plugins")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("dark");
|
||||
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(2);
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("shows grouped skills and plugins typeahead without users", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
navigateMock.mockReset();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
render(<Header />);
|
||||
|
||||
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "weather" } });
|
||||
|
||||
const labels = Array.from(
|
||||
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
|
||||
)
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
const typeahead = screen.getByRole("listbox");
|
||||
expect(within(typeahead).getByText("Skills")).toBeTruthy();
|
||||
expect(screen.getByText("Weather Skill")).toBeTruthy();
|
||||
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
|
||||
expect(screen.getByText("Weather Plugin")).toBeTruthy();
|
||||
expect(within(typeahead).queryByText("Users")).toBeNull();
|
||||
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
|
||||
});
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/search",
|
||||
search: { q: "weather", type: "skills" },
|
||||
});
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("falls back to typed skill search when a typeahead skill has no owner handle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
navigateMock.mockReset();
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
...defaultUnifiedSearchResult,
|
||||
skillResults: [
|
||||
{
|
||||
...defaultUnifiedSearchResult.skillResults[0],
|
||||
ownerHandle: null,
|
||||
skill: {
|
||||
...defaultUnifiedSearchResult.skillResults[0].skill,
|
||||
ownerUserId: "users:opaque-id",
|
||||
ownerPublisherId: "publishers:opaque-id",
|
||||
},
|
||||
},
|
||||
],
|
||||
pluginResults: [],
|
||||
pluginCount: 0,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
render(<Header />);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "weather" } });
|
||||
fireEvent.click(screen.getByRole("option", { name: /Weather Skill/i }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/search",
|
||||
search: { q: "weather", type: "skills" },
|
||||
});
|
||||
expect(navigateMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/publishers%3Aopaque-id/weather",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a single no-results state without section footers", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
isSearching: false,
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "zzzz" } });
|
||||
|
||||
const typeahead = screen.getByRole("listbox");
|
||||
expect(within(typeahead).getByText('No skills or plugins found for "zzzz"')).toBeTruthy();
|
||||
expect(within(typeahead).queryByText("Skills")).toBeNull();
|
||||
expect(within(typeahead).queryByText("Plugins")).toBeNull();
|
||||
expect(within(typeahead).queryByText('See skill results for "zzzz"')).toBeNull();
|
||||
expect(within(typeahead).queryByText('See plugin results for "zzzz"')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
|
||||
|
||||
const labels = Array.from(document.querySelectorAll(".mobile-nav-section .mobile-nav-link"))
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
|
||||
});
|
||||
|
||||
it("keeps Stars out of signed-in header navigation", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
authStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: {
|
||||
displayName: "Patrick",
|
||||
email: "patrick@example.com",
|
||||
handle: "patrick",
|
||||
image: null,
|
||||
name: "Patrick",
|
||||
},
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.queryByText("Stars")).toBeNull();
|
||||
expect(screen.getAllByText("Dashboard").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Settings")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
@@ -51,10 +50,10 @@ describe("search route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the users type filter", () => {
|
||||
it("ignores the users type filter", () => {
|
||||
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
|
||||
q: "vincent",
|
||||
type: "users",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => ({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
userCount: 0,
|
||||
isSearching: false,
|
||||
}),
|
||||
}));
|
||||
@@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
|
||||
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
@@ -64,7 +59,7 @@ describe("search route", () => {
|
||||
const Component = route.__config.component as ComponentType;
|
||||
const rendered = render(<Component />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
|
||||
expect(input.value).toBe("first");
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft" } });
|
||||
@@ -74,7 +69,16 @@ describe("search route", () => {
|
||||
rendered.rerender(<Component />);
|
||||
|
||||
expect(
|
||||
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
|
||||
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
|
||||
).toBe("second");
|
||||
});
|
||||
|
||||
it("does not render a public users search tab", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const queryMock = vi.fn();
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/users/index")).Route as unknown as {
|
||||
__config: {
|
||||
component?: ComponentType;
|
||||
validateSearch?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("users route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
queryMock.mockReset();
|
||||
queryMock.mockResolvedValue({ items: [], total: 0 });
|
||||
});
|
||||
|
||||
it("does not expose public user search", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
await waitFor(() => expect(queryMock).toHaveBeenCalled());
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 });
|
||||
expect(screen.queryByPlaceholderText(/search users/i)).toBeNull();
|
||||
expect(route.__config.validateSearch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+347
-24
@@ -1,19 +1,20 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useMemo, useState } from "react";
|
||||
import { ArrowRight, Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
filterNavItems,
|
||||
type NavIconName,
|
||||
PRIMARY_NAV_ITEMS,
|
||||
} from "../lib/nav-items";
|
||||
import { filterNavItems, type NavIconName, PRIMARY_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 { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedPluginResult,
|
||||
type UnifiedSkillResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -37,6 +38,24 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
|
||||
ghost: Ghost,
|
||||
};
|
||||
|
||||
type TypeaheadItem =
|
||||
| {
|
||||
kind: "skill";
|
||||
key: string;
|
||||
result: UnifiedSkillResult;
|
||||
}
|
||||
| {
|
||||
kind: "plugin";
|
||||
key: string;
|
||||
result: UnifiedPluginResult;
|
||||
}
|
||||
| {
|
||||
kind: "footer";
|
||||
key: string;
|
||||
section: "skills" | "plugins";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
@@ -62,10 +81,67 @@ export default function Header() {
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
const [navSearchQuery, setNavSearchQuery] = useState("");
|
||||
const [typeaheadOpen, setTypeaheadOpen] = useState(false);
|
||||
const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0);
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const searchWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const ThemeModeIcon = getThemeModeIcon(mode);
|
||||
const nextThemeMode = getNextThemeMode(mode);
|
||||
const trimmedNavSearchQuery = navSearchQuery.trim();
|
||||
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
|
||||
const {
|
||||
skillResults,
|
||||
skillCount,
|
||||
pluginResults,
|
||||
pluginCount,
|
||||
isSearching: typeaheadSearching,
|
||||
} = useUnifiedSearch(navSearchQuery, "all", {
|
||||
debounceMs: 180,
|
||||
enabled: showTypeahead,
|
||||
limits: { skills: 4, plugins: 4 },
|
||||
});
|
||||
const typeaheadItems = useMemo<TypeaheadItem[]>(() => {
|
||||
if (!showTypeahead) return [];
|
||||
const items: TypeaheadItem[] = [];
|
||||
for (const result of skillResults) {
|
||||
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
|
||||
}
|
||||
if (skillCount > 0) {
|
||||
items.push({
|
||||
kind: "footer",
|
||||
key: "footer-skills",
|
||||
section: "skills",
|
||||
label: `See skill results for "${trimmedNavSearchQuery}"`,
|
||||
});
|
||||
}
|
||||
for (const result of pluginResults) {
|
||||
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
|
||||
}
|
||||
if (pluginCount > 0) {
|
||||
items.push({
|
||||
kind: "footer",
|
||||
key: "footer-plugins",
|
||||
section: "plugins",
|
||||
label: `See plugin results for "${trimmedNavSearchQuery}"`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setTypeaheadActiveIndex(0);
|
||||
}, [trimmedNavSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!typeaheadOpen) return () => {};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (searchWrapRef.current?.contains(event.target as Node)) return;
|
||||
setTypeaheadOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [typeaheadOpen]);
|
||||
|
||||
const setThemeMode = (next: "system" | "light" | "dark") => {
|
||||
applyTheme(next, theme);
|
||||
@@ -89,9 +165,72 @@ export default function Header() {
|
||||
: { q, type: undefined },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
const navigateToTypeaheadItem = (item: TypeaheadItem) => {
|
||||
if (item.kind === "skill") {
|
||||
const resultOwnerHandle = item.result.ownerHandle?.trim();
|
||||
if (!resultOwnerHandle) {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: trimmedNavSearchQuery, type: "skills" },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
void navigate({
|
||||
to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`,
|
||||
});
|
||||
} else if (item.kind === "plugin") {
|
||||
void navigate({
|
||||
to: "/plugins/$name",
|
||||
params: { name: item.result.plugin.name },
|
||||
});
|
||||
} else {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: trimmedNavSearchQuery, type: item.section },
|
||||
});
|
||||
}
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSoulMode) return;
|
||||
if (event.key === "Escape") {
|
||||
setTypeaheadOpen(false);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return;
|
||||
if (!showTypeahead || typeaheadItems.length === 0) {
|
||||
if (event.key === "ArrowDown" && trimmedNavSearchQuery) {
|
||||
setTypeaheadOpen(true);
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setTypeaheadActiveIndex((index) => (index + 1) % typeaheadItems.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setTypeaheadActiveIndex(
|
||||
(index) => (index - 1 + typeaheadItems.length) % typeaheadItems.length,
|
||||
);
|
||||
} else if (event.key === "Enter") {
|
||||
const activeItem = typeaheadItems[typeaheadActiveIndex];
|
||||
if (!activeItem) return;
|
||||
event.preventDefault();
|
||||
navigateToTypeaheadItem(activeItem);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="navbar">
|
||||
<div className="navbar-inner">
|
||||
@@ -176,17 +315,42 @@ export default function Header() {
|
||||
<span className="brand-name brand-name-responsive">{siteName}</span>
|
||||
</Link>
|
||||
|
||||
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="search"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
aria-label="Search"
|
||||
/>
|
||||
</form>
|
||||
<div className="navbar-search-wrap" ref={searchWrapRef}>
|
||||
<form
|
||||
className="navbar-search"
|
||||
onSubmit={handleNavSearch}
|
||||
role="search"
|
||||
aria-label="Site search"
|
||||
>
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="search"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => {
|
||||
setNavSearchQuery(e.target.value);
|
||||
setTypeaheadOpen(true);
|
||||
}}
|
||||
onFocus={() => setTypeaheadOpen(true)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
aria-label="Search"
|
||||
aria-expanded={showTypeahead}
|
||||
aria-controls="navbar-search-typeahead"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</form>
|
||||
{showTypeahead ? (
|
||||
<SearchTypeahead
|
||||
activeIndex={typeaheadActiveIndex}
|
||||
items={typeaheadItems}
|
||||
loading={typeaheadSearching}
|
||||
onHoverItem={setTypeaheadActiveIndex}
|
||||
onSelectItem={navigateToTypeaheadItem}
|
||||
query={trimmedNavSearchQuery}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="navbar-top-links" aria-label="Primary">
|
||||
{isSoulMode ? (
|
||||
@@ -248,9 +412,6 @@ 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>
|
||||
@@ -287,7 +448,9 @@ export default function Header() {
|
||||
"github",
|
||||
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
|
||||
).catch((error) => {
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
setAuthError(
|
||||
getUserFacingAuthError(error, "Sign in failed. Please try again."),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -305,19 +468,179 @@ export default function Header() {
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="text"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchTypeahead({
|
||||
activeIndex,
|
||||
items,
|
||||
loading,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
query,
|
||||
}: {
|
||||
activeIndex: number;
|
||||
items: TypeaheadItem[];
|
||||
loading: boolean;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
query: string;
|
||||
}) {
|
||||
const skillItems = items.filter((item) => item.kind === "skill");
|
||||
const pluginItems = items.filter((item) => item.kind === "plugin");
|
||||
const footerItems = items.filter((item) => item.kind === "footer");
|
||||
const skillsFooter = footerItems.find(
|
||||
(item) => item.kind === "footer" && item.section === "skills",
|
||||
);
|
||||
const pluginsFooter = footerItems.find(
|
||||
(item) => item.kind === "footer" && item.section === "plugins",
|
||||
);
|
||||
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
|
||||
<TypeaheadSection
|
||||
activeIndex={activeIndex}
|
||||
items={items}
|
||||
label="Skills"
|
||||
sectionItems={skillItems}
|
||||
footer={skillsFooter}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
<TypeaheadSection
|
||||
activeIndex={activeIndex}
|
||||
items={items}
|
||||
label="Plugins"
|
||||
sectionItems={pluginItems}
|
||||
footer={pluginsFooter}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
{loading && !hasMatches ? (
|
||||
<div className="navbar-search-typeahead-status">Searching...</div>
|
||||
) : null}
|
||||
{!loading && !hasMatches ? (
|
||||
<div className="navbar-search-typeahead-status">
|
||||
No skills or plugins found for "{query}"
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeaheadSection({
|
||||
activeIndex,
|
||||
footer,
|
||||
items,
|
||||
label,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
sectionItems,
|
||||
}: {
|
||||
activeIndex: number;
|
||||
footer: TypeaheadItem | undefined;
|
||||
items: TypeaheadItem[];
|
||||
label: string;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
sectionItems: TypeaheadItem[];
|
||||
}) {
|
||||
if (sectionItems.length === 0 && !footer) return null;
|
||||
return (
|
||||
<div className="navbar-search-typeahead-section">
|
||||
<div className="navbar-search-typeahead-heading">{label}</div>
|
||||
{sectionItems.map((item) => (
|
||||
<TypeaheadRow
|
||||
key={item.key}
|
||||
active={items[activeIndex]?.key === item.key}
|
||||
item={item}
|
||||
index={items.findIndex((candidate) => candidate.key === item.key)}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
))}
|
||||
{footer ? (
|
||||
<TypeaheadRow
|
||||
active={items[activeIndex]?.key === footer.key}
|
||||
item={footer}
|
||||
index={items.findIndex((candidate) => candidate.key === footer.key)}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeaheadRow({
|
||||
active,
|
||||
index,
|
||||
item,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
}: {
|
||||
active: boolean;
|
||||
index: number;
|
||||
item: TypeaheadItem;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
}) {
|
||||
const body = getTypeaheadRowBody(item);
|
||||
return (
|
||||
<button
|
||||
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseEnter={() => onHoverItem(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
{body.icon ? <span className="navbar-search-typeahead-icon">{body.icon}</span> : null}
|
||||
<span className="navbar-search-typeahead-copy">
|
||||
<span className="navbar-search-typeahead-title">{body.title}</span>
|
||||
{body.meta ? <span className="navbar-search-typeahead-meta">{body.meta}</span> : null}
|
||||
</span>
|
||||
{item.kind === "footer" ? <ArrowRight size={14} aria-hidden="true" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getTypeaheadRowBody(item: TypeaheadItem) {
|
||||
if (item.kind === "skill") {
|
||||
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
|
||||
return {
|
||||
icon: "S",
|
||||
title: item.result.skill.displayName,
|
||||
meta: `${owner} / ${item.result.skill.slug}`,
|
||||
};
|
||||
}
|
||||
if (item.kind === "plugin") {
|
||||
return {
|
||||
icon: "P",
|
||||
title: item.result.plugin.displayName,
|
||||
meta: item.result.plugin.ownerHandle
|
||||
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
|
||||
: item.result.plugin.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: null,
|
||||
title: item.label,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
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 { Calendar, Download, History, Package, Scale, Settings, Star, Upload } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
@@ -171,7 +171,7 @@ export function SkillHeader({
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
{isStaff || settingsHref ? (
|
||||
{canManage || isStaff || settingsHref ? (
|
||||
<div className="skill-title-actions">
|
||||
{isStaff ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
@@ -180,6 +180,14 @@ export function SkillHeader({
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? (
|
||||
<Button asChild variant="outline" size="sm" className="skill-settings-link">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{settingsHref ? (
|
||||
<Button asChild variant="outline" size="sm" className="skill-settings-link">
|
||||
<a href={settingsHref}>
|
||||
|
||||
+46
-47
@@ -1,9 +1,9 @@
|
||||
import { useAction } from "convex/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
|
||||
import type { PublicUser } from "./publicUser";
|
||||
|
||||
export type UnifiedSearchType = "all" | "skills" | "plugins";
|
||||
|
||||
export type UnifiedSkillResult = {
|
||||
type: "skill";
|
||||
@@ -27,33 +27,44 @@ export type UnifiedPluginResult = {
|
||||
plugin: PackageListItem;
|
||||
};
|
||||
|
||||
export type UnifiedUserResult = {
|
||||
type: "user";
|
||||
user: PublicUser;
|
||||
};
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult;
|
||||
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
|
||||
type UnifiedSearchOptions = {
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
limits?: {
|
||||
skills?: number;
|
||||
plugins?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function useUnifiedSearch(
|
||||
query: string,
|
||||
activeType: "all" | "skills" | "plugins" | "users",
|
||||
activeType: UnifiedSearchType,
|
||||
options: UnifiedSearchOptions = {},
|
||||
) {
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
const [results, setResults] = useState<UnifiedResult[]>([]);
|
||||
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
|
||||
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
|
||||
const [skillCount, setSkillCount] = useState(0);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [userCount, setUserCount] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const requestRef = useRef(0);
|
||||
const debounceMs = options.debounceMs ?? 300;
|
||||
const enabled = options.enabled ?? true;
|
||||
const skillLimit = options.limits?.skills ?? 25;
|
||||
const pluginLimit = options.limits?.plugins ?? 25;
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
if (!enabled || !trimmed) {
|
||||
requestRef.current += 1;
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
setIsSearching(false);
|
||||
return () => {};
|
||||
}
|
||||
@@ -65,40 +76,34 @@ export function useUnifiedSearch(
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const promises: [
|
||||
Promise<unknown> | null,
|
||||
Promise<{ items: PackageListItem[] }> | null,
|
||||
Promise<{ items: PublicUser[] }> | null,
|
||||
] = [null, null, null];
|
||||
const promises: [Promise<unknown> | null, Promise<{ items: PackageListItem[] }> | null] =
|
||||
[null, null];
|
||||
|
||||
if (activeType === "all" || activeType === "skills") {
|
||||
promises[0] = searchSkills({
|
||||
query: trimmed,
|
||||
limit: 25,
|
||||
limit: skillLimit,
|
||||
nonSuspiciousOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "plugins") {
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "users") {
|
||||
promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
promises.map((p) => p ?? Promise.resolve(null)),
|
||||
);
|
||||
const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null)));
|
||||
|
||||
if (requestId !== requestRef.current) return;
|
||||
|
||||
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
|
||||
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
|
||||
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
|
||||
|
||||
const skillResults: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
|
||||
const nextSkillResults: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{
|
||||
skill: UnifiedSkillResult["skill"];
|
||||
ownerHandle: string | null;
|
||||
score: number;
|
||||
}>) ?? []
|
||||
).map((entry) => ({
|
||||
type: "skill" as const,
|
||||
skill: entry.skill,
|
||||
@@ -106,32 +111,25 @@ export function useUnifiedSearch(
|
||||
score: entry.score,
|
||||
}));
|
||||
|
||||
const pluginResults: UnifiedPluginResult[] = (
|
||||
const nextPluginResults: UnifiedPluginResult[] = (
|
||||
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
|
||||
).map((item) => ({
|
||||
type: "plugin" as const,
|
||||
plugin: item,
|
||||
}));
|
||||
|
||||
setSkillCount(skillResults.length);
|
||||
setPluginCount(pluginResults.length);
|
||||
const userResults: UnifiedUserResult[] = (
|
||||
(usersRaw as { items: PublicUser[] })?.items ?? []
|
||||
).map((user) => ({
|
||||
type: "user" as const,
|
||||
user,
|
||||
}));
|
||||
setUserCount(userResults.length);
|
||||
setSkillCount(nextSkillResults.length);
|
||||
setPluginCount(nextPluginResults.length);
|
||||
setSkillResults(nextSkillResults);
|
||||
setPluginResults(nextPluginResults);
|
||||
|
||||
const merged: UnifiedResult[] = [];
|
||||
if (activeType === "all") {
|
||||
merged.push(...skillResults, ...pluginResults, ...userResults);
|
||||
merged.push(...nextSkillResults, ...nextPluginResults);
|
||||
} else if (activeType === "skills") {
|
||||
merged.push(...skillResults);
|
||||
} else if (activeType === "plugins") {
|
||||
merged.push(...pluginResults);
|
||||
merged.push(...nextSkillResults);
|
||||
} else {
|
||||
merged.push(...userResults);
|
||||
merged.push(...nextPluginResults);
|
||||
}
|
||||
|
||||
setResults(merged);
|
||||
@@ -139,9 +137,10 @@ export function useUnifiedSearch(
|
||||
console.error("Unified search failed:", error);
|
||||
if (requestId === requestRef.current) {
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestRef.current) {
|
||||
@@ -149,10 +148,10 @@ export function useUnifiedSearch(
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}, debounceMs);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, activeType, searchSkills]);
|
||||
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
|
||||
|
||||
return { results, skillCount, pluginCount, userCount, isSearching };
|
||||
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { Settings } from "./settings";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
@@ -16,6 +18,15 @@ vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => useAuthActionsMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Settings", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
@@ -35,4 +46,30 @@ describe("Settings", () => {
|
||||
expect(screen.getByText(/sign in to access settings\./i)).toBeTruthy();
|
||||
expect(useQueryMock.mock.calls.some(([, args]) => args === "skip")).toBe(true);
|
||||
});
|
||||
|
||||
it("links to starred skills from signed-in settings", () => {
|
||||
useQueryMock.mockImplementation((query, args) => {
|
||||
if (query === api.users.me) {
|
||||
return {
|
||||
_id: "user_123",
|
||||
displayName: "Patrick",
|
||||
name: "Patrick",
|
||||
handle: "patrick",
|
||||
email: "patrick@example.com",
|
||||
image: null,
|
||||
bio: null,
|
||||
};
|
||||
}
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "publisherHandle" in args) {
|
||||
return undefined;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
render(<Settings />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Stars" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "View stars" }).getAttribute("href")).toBe("/stars");
|
||||
});
|
||||
});
|
||||
|
||||
+25
-336
@@ -1,6 +1,5 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
Star,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
@@ -28,13 +28,6 @@ function Home() {
|
||||
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
|
||||
}
|
||||
|
||||
// ═══ Slot machine word pool (13 words = 1/13 jackpot odds) ═══
|
||||
const SLOT_WORDS = [
|
||||
"Equip", "Install", "Unleash", "Ship", "Build",
|
||||
"Create", "Deploy", "Launch", "Hack", "Scale",
|
||||
"Forge", "Craft", "Wield",
|
||||
];
|
||||
|
||||
function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
@@ -104,262 +97,8 @@ function SkillsHome() {
|
||||
// Build carousel cards from highlighted data
|
||||
const carouselCards = highlighted.length > 0 ? highlighted.slice(0, 6) : [];
|
||||
|
||||
// ═══ SLOT MACHINE EASTER EGG ═══
|
||||
const HACK_INDEX = SLOT_WORDS.indexOf("Hack");
|
||||
const clickTimesRef = useRef<number[]>([]);
|
||||
const [slotState, setSlotState] = useState<
|
||||
| null
|
||||
| { phase: "spinning" }
|
||||
| { phase: "stopped"; results: [number, number, number]; won: boolean; isHackJackpot: boolean }
|
||||
>(null);
|
||||
const slotTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const [slotReelOffsets, setSlotReelOffsets] = useState<[number, number, number]>([0, 0, 0]);
|
||||
const [stoppedReels, setStoppedReels] = useState<Set<number>>(new Set());
|
||||
const confettiRef = useRef<HTMLCanvasElement>(null);
|
||||
const spinIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const cooldownUntilRef = useRef<number>(0);
|
||||
|
||||
// Clean up timers/intervals if the component unmounts mid-spin
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const t of slotTimersRef.current) clearTimeout(t);
|
||||
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const triggerSlots = useCallback(() => {
|
||||
// Clean up any previous timers
|
||||
for (const t of slotTimersRef.current) clearTimeout(t);
|
||||
slotTimersRef.current = [];
|
||||
if (spinIntervalRef.current) clearInterval(spinIntervalRef.current);
|
||||
|
||||
setSlotState({ phase: "spinning" });
|
||||
setStoppedReels(new Set());
|
||||
|
||||
// Controlled odds: ~1/25 any jackpot, ~1/100 Hack jackpot
|
||||
let r0: number, r1: number, r2: number;
|
||||
const isJackpot = Math.random() < 1 / 25;
|
||||
if (isJackpot) {
|
||||
// 25% of jackpots are Hack (1/25 × 1/4 = 1/100 overall)
|
||||
const isHack = Math.random() < 0.25;
|
||||
if (isHack) {
|
||||
r0 = HACK_INDEX;
|
||||
} else {
|
||||
// Pick any word except Hack
|
||||
let idx = Math.floor(Math.random() * (SLOT_WORDS.length - 1));
|
||||
if (idx >= HACK_INDEX) idx++;
|
||||
r0 = idx;
|
||||
}
|
||||
r1 = r0;
|
||||
r2 = r0;
|
||||
} else {
|
||||
// Normal spin — re-roll if accidental triple match
|
||||
do {
|
||||
r0 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
r1 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
r2 = Math.floor(Math.random() * SLOT_WORDS.length);
|
||||
} while (r0 === r1 && r1 === r2);
|
||||
}
|
||||
const results: [number, number, number] = [r0, r1, r2];
|
||||
const landed = new Set<number>();
|
||||
|
||||
// Animate fast offset cycling — only cycle reels that haven't landed
|
||||
let frame = 0;
|
||||
const spinInterval = setInterval(() => {
|
||||
frame++;
|
||||
setSlotReelOffsets((prev) => [
|
||||
landed.has(0) ? prev[0] : (frame * 3) % SLOT_WORDS.length,
|
||||
landed.has(1) ? prev[1] : (frame * 5 + 4) % SLOT_WORDS.length,
|
||||
landed.has(2) ? prev[2] : (frame * 7 + 9) % SLOT_WORDS.length,
|
||||
]);
|
||||
}, 60);
|
||||
spinIntervalRef.current = spinInterval;
|
||||
|
||||
// Stop reels sequentially with a satisfying stagger
|
||||
const stopReel = (reelIdx: 0 | 1 | 2, delay: number) => {
|
||||
const t = setTimeout(() => {
|
||||
landed.add(reelIdx);
|
||||
setStoppedReels((prev) => new Set(prev).add(reelIdx));
|
||||
setSlotReelOffsets((prev) => {
|
||||
const next = [...prev] as [number, number, number];
|
||||
next[reelIdx] = results[reelIdx];
|
||||
return next;
|
||||
});
|
||||
}, delay);
|
||||
slotTimersRef.current.push(t);
|
||||
};
|
||||
|
||||
stopReel(0, 1200);
|
||||
stopReel(1, 1800);
|
||||
|
||||
const tFinal = setTimeout(() => {
|
||||
clearInterval(spinInterval);
|
||||
spinIntervalRef.current = null;
|
||||
landed.add(2);
|
||||
setStoppedReels(new Set([0, 1, 2]));
|
||||
setSlotReelOffsets(results);
|
||||
const won = r0 === r1 && r1 === r2;
|
||||
const isHackJackpot = won && r0 === HACK_INDEX;
|
||||
setSlotState({ phase: "stopped", results, won, isHackJackpot });
|
||||
if (won) {
|
||||
fireConfetti(isHackJackpot);
|
||||
}
|
||||
// Cooldown: 18s after win, 3s after loss
|
||||
const displayTime = won ? 10000 : 2400;
|
||||
const cooldownTime = won ? 18000 : 3000;
|
||||
cooldownUntilRef.current = Date.now() + cooldownTime;
|
||||
const tReset = setTimeout(() => {
|
||||
setSlotState(null);
|
||||
setStoppedReels(new Set());
|
||||
}, displayTime);
|
||||
slotTimersRef.current.push(tReset);
|
||||
}, 2400);
|
||||
slotTimersRef.current.push(tFinal);
|
||||
}, []);
|
||||
|
||||
const handleLabelClick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
// Respect cooldown period
|
||||
if (now < cooldownUntilRef.current) return;
|
||||
clickTimesRef.current.push(now);
|
||||
// Keep only last 3 clicks
|
||||
if (clickTimesRef.current.length > 3) {
|
||||
clickTimesRef.current = clickTimesRef.current.slice(-3);
|
||||
}
|
||||
if (clickTimesRef.current.length === 3) {
|
||||
const first = clickTimesRef.current[0];
|
||||
const last = clickTimesRef.current[2];
|
||||
if (last - first < 800 && !slotState) {
|
||||
clickTimesRef.current = [];
|
||||
triggerSlots();
|
||||
}
|
||||
}
|
||||
}, [slotState, triggerSlots]);
|
||||
|
||||
const fireConfetti = (isHackJackpot: boolean) => {
|
||||
const canvas = confettiRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
canvas.style.display = "block";
|
||||
|
||||
const STANDARD_COLORS = [
|
||||
"#d4453a", "#ff6b6b", "#ffd93d", "#6bcb77",
|
||||
"#4d96ff", "#ff6f91", "#845ec2", "#ffc75f",
|
||||
];
|
||||
const OCEAN_COLORS = [
|
||||
"#0ea5e9", "#06b6d4", "#14b8a6", "#22d3ee",
|
||||
"#38bdf8", "#67e8f9", "#a5f3fc", "#2dd4bf",
|
||||
"#d4453a", "#ff6b6b",
|
||||
];
|
||||
const colors = isHackJackpot ? OCEAN_COLORS : STANDARD_COLORS;
|
||||
|
||||
type Particle = {
|
||||
x: number; y: number; vx: number; vy: number;
|
||||
w: number; h: number; color: string; rot: number; vr: number;
|
||||
life: number; shape: "rect" | "bubble" | "claw";
|
||||
};
|
||||
const particles: Particle[] = [];
|
||||
|
||||
const count = isHackJackpot ? 200 : 150;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isBubble = isHackJackpot && Math.random() < 0.35;
|
||||
const isClaw = isHackJackpot && !isBubble && Math.random() < 0.2;
|
||||
particles.push({
|
||||
x: canvas.width / 2 + (Math.random() - 0.5) * 300,
|
||||
y: canvas.height * 0.35,
|
||||
vx: (Math.random() - 0.5) * 18,
|
||||
vy: isHackJackpot
|
||||
? -Math.random() * 14 - 2 + (isBubble ? -4 : 0)
|
||||
: -Math.random() * 16 - 4,
|
||||
w: isBubble ? Math.random() * 8 + 4 : Math.random() * 10 + 4,
|
||||
h: isBubble ? 0 : Math.random() * 6 + 3,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
vr: (Math.random() - 0.5) * 0.3,
|
||||
life: isHackJackpot ? 1.3 : 1,
|
||||
shape: isClaw ? "claw" : isBubble ? "bubble" : "rect",
|
||||
});
|
||||
}
|
||||
|
||||
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
|
||||
// Simple lobster claw shape
|
||||
context.beginPath();
|
||||
context.moveTo(0, size * 0.5);
|
||||
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
|
||||
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
|
||||
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
|
||||
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let alive = false;
|
||||
for (const p of particles) {
|
||||
if (p.life <= 0) continue;
|
||||
alive = true;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += p.shape === "bubble" ? 0.15 : 0.4;
|
||||
p.vx *= 0.99;
|
||||
p.rot += p.vr;
|
||||
p.life -= isHackJackpot ? 0.005 : 0.008;
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot);
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === "bubble") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.w, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = p.color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha *= 0.7;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha *= 0.15;
|
||||
ctx.fill();
|
||||
} else if (p.shape === "claw") {
|
||||
drawClaw(ctx, p.w);
|
||||
} else {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
if (alive) {
|
||||
requestAnimationFrame(draw);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas.style.display = "none";
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
|
||||
const offset = slotReelOffsets[reelIdx];
|
||||
const word = SLOT_WORDS[offset];
|
||||
const isReelSpinning = slotState !== null && !stoppedReels.has(reelIdx);
|
||||
return (
|
||||
<span className={`home-v2-slot-reel ${isReelSpinning ? "spinning" : ""}`}>
|
||||
<span className="home-v2-slot-word">{word}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="home-v2-main">
|
||||
{/* Confetti canvas for slot machine wins */}
|
||||
<canvas
|
||||
ref={confettiRef}
|
||||
className="home-v2-confetti"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{/* ═══ HERO ═══ */}
|
||||
<section className="home-v2-hero">
|
||||
<div className="home-v2-hero-bg">
|
||||
@@ -370,81 +109,41 @@ function SkillsHome() {
|
||||
<div className="home-v2-ring home-v2-ring-3" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`home-v2-hero-label ${slotState ? "home-v2-hero-label-active" : ""}`}
|
||||
onClick={handleLabelClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleLabelClick(); }}
|
||||
>
|
||||
BUILT BY THE COMMUNITY.
|
||||
</div>
|
||||
|
||||
{slotState ? (
|
||||
<h1 className={`home-v2-headline home-v2-headline-slots${
|
||||
slotState.phase === "stopped" && slotState.won
|
||||
? slotState.isHackJackpot
|
||||
? " home-v2-headline-jackpot home-v2-headline-hack"
|
||||
: " home-v2-headline-jackpot"
|
||||
: ""
|
||||
}`}>
|
||||
{slotState.phase === "stopped" && slotState.isHackJackpot && (
|
||||
<img
|
||||
src="/clawd-mark.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="home-v2-hack-lobster"
|
||||
/>
|
||||
)}
|
||||
<span className="home-v2-headline-inner">
|
||||
{renderSlotReel(0)}
|
||||
<span className="home-v2-sep" />
|
||||
{renderSlotReel(1)}
|
||||
<span className="home-v2-sep" />
|
||||
{renderSlotReel(2)}
|
||||
</span>
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="home-v2-headline">
|
||||
<span className="home-v2-headline-inner">
|
||||
<span className="home-v2-action-word">Equip</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-action-word">Install</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-cycle-wrap">
|
||||
<span className="home-v2-cycle-track">
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
<span className="home-v2-cycle-word">Ship.</span>
|
||||
<span className="home-v2-cycle-word">Build.</span>
|
||||
<span className="home-v2-cycle-word">Create.</span>
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
</span>
|
||||
<h1 className="home-v2-headline">
|
||||
<span className="home-v2-headline-inner">
|
||||
<span className="home-v2-action-word">Equip</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-action-word">Install</span>
|
||||
<span className="home-v2-sep" />
|
||||
<span className="home-v2-cycle-wrap">
|
||||
<span className="home-v2-cycle-track">
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
<span className="home-v2-cycle-word">Ship.</span>
|
||||
<span className="home-v2-cycle-word">Build.</span>
|
||||
<span className="home-v2-cycle-word">Create.</span>
|
||||
<span className="home-v2-cycle-word">Unleash.</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<p className="home-v2-sub">Tools built by thousands, ready in one search.</p>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="home-v2-search-container">
|
||||
<form className="home-v2-search-bar" onSubmit={handleSearch}>
|
||||
<Search className="home-v2-search-icon" size={20} />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="What are you looking for?"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<kbd>/</kbd>
|
||||
<button type="submit" className="home-v2-search-go">
|
||||
<span className="home-v2-search-go-label">Search</span>{" "}
|
||||
<ArrowRight size={16} />
|
||||
<span className="home-v2-search-go-label">Search</span> <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="home-v2-suggestions">
|
||||
<span className="home-v2-suggestions-label">Try</span>
|
||||
<button
|
||||
type="button"
|
||||
className="home-v2-suggestion"
|
||||
@@ -516,12 +215,10 @@ function SkillsHome() {
|
||||
<div className="home-v2-c-footer">
|
||||
<div className="home-v2-c-stats">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
@@ -554,12 +251,10 @@ function SkillsHome() {
|
||||
<div className="home-v2-c-footer">
|
||||
<div className="home-v2-c-stats">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
@@ -673,11 +368,7 @@ function SkillsHome() {
|
||||
</div>
|
||||
<div className="home-v2-trending-grid">
|
||||
{popular.slice(0, 6).map((entry) => (
|
||||
<Link
|
||||
key={entry.skill._id}
|
||||
to={skillLink(entry)}
|
||||
className="home-v2-trend-card"
|
||||
>
|
||||
<Link key={entry.skill._id} to={skillLink(entry)} className="home-v2-trend-card">
|
||||
<div className="home-v2-trend-head">
|
||||
<div className="home-v2-trend-title">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
@@ -692,12 +383,10 @@ function SkillsHome() {
|
||||
<div className="home-v2-trend-bottom">
|
||||
<div className="home-v2-trend-signals">
|
||||
<span>
|
||||
<Star size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.stars)}
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} />{" "}
|
||||
{formatStat(entry.skill.stats?.downloads)}
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-trend-install">
|
||||
|
||||
+13
-43
@@ -3,28 +3,24 @@ import { Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PluginListItem } from "../components/PluginListItem";
|
||||
import { SkillListItem } from "../components/SkillListItem";
|
||||
import { UserListItem } from "../components/UserListItem";
|
||||
import { Card } from "../components/ui/card";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedSearchType,
|
||||
type UnifiedPluginResult,
|
||||
type UnifiedSkillResult,
|
||||
type UnifiedUserResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
|
||||
type SearchState = {
|
||||
q?: string;
|
||||
type?: "all" | "skills" | "plugins" | "users";
|
||||
type?: UnifiedSearchType;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
validateSearch: (search): SearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
|
||||
type:
|
||||
search.type === "skills" || search.type === "plugins" || search.type === "users"
|
||||
? search.type
|
||||
: undefined,
|
||||
type: search.type === "skills" || search.type === "plugins" ? search.type : undefined,
|
||||
}),
|
||||
component: UnifiedSearchPage,
|
||||
});
|
||||
@@ -39,7 +35,7 @@ function UnifiedSearchPage() {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
const { results, skillCount, pluginCount, userCount, isSearching } = useUnifiedSearch(
|
||||
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
|
||||
search.q ?? "",
|
||||
activeType,
|
||||
);
|
||||
@@ -52,7 +48,7 @@ function UnifiedSearchPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const setType = (type: "all" | "skills" | "plugins" | "users") => {
|
||||
const setType = (type: UnifiedSearchType) => {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: search.q, type: type === "all" ? undefined : type },
|
||||
@@ -74,11 +70,11 @@ function UnifiedSearchPage() {
|
||||
|
||||
<form className="search-page-form" onSubmit={handleSearch}>
|
||||
<div className="browse-search-bar max-w-[560px] flex-1">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
type="text"
|
||||
placeholder="Search skills, plugins, users..."
|
||||
placeholder="Search skills and plugins..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
@@ -100,9 +96,7 @@ function UnifiedSearchPage() {
|
||||
onClick={() => setType("skills")}
|
||||
>
|
||||
Skills
|
||||
{skillCount > 0 ? (
|
||||
<span className="search-tab-count">{skillCount}</span>
|
||||
) : null}
|
||||
{skillCount > 0 ? <span className="search-tab-count">{skillCount}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
|
||||
@@ -110,17 +104,7 @@ function UnifiedSearchPage() {
|
||||
onClick={() => setType("plugins")}
|
||||
>
|
||||
Plugins
|
||||
{pluginCount > 0 ? (
|
||||
<span className="search-tab-count">{pluginCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
className={`search-tab${activeType === "users" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setType("users")}
|
||||
>
|
||||
Users
|
||||
{userCount > 0 ? <span className="search-tab-count">{userCount}</span> : null}
|
||||
{pluginCount > 0 ? <span className="search-tab-count">{pluginCount}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -130,9 +114,7 @@ function UnifiedSearchPage() {
|
||||
</Card>
|
||||
) : !search.q ? (
|
||||
<Card className="text-center p-10">
|
||||
<p className="text-ink-soft">
|
||||
Enter a search term to find skills, plugins, and users
|
||||
</p>
|
||||
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
|
||||
</Card>
|
||||
) : results.length === 0 ? (
|
||||
<Card className="text-center p-10">
|
||||
@@ -143,10 +125,8 @@ function UnifiedSearchPage() {
|
||||
{results.map((item) =>
|
||||
item.type === "skill" ? (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
) : item.type === "plugin" ? (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
) : (
|
||||
<UserResultRow key={`user-${item.user._id}`} result={item} />
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@@ -157,19 +137,9 @@ function UnifiedSearchPage() {
|
||||
|
||||
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
|
||||
const skill = result.skill as unknown as PublicSkill;
|
||||
return (
|
||||
<SkillListItem
|
||||
skill={skill}
|
||||
ownerHandle={result.ownerHandle}
|
||||
/>
|
||||
);
|
||||
return <SkillListItem skill={skill} ownerHandle={result.ownerHandle} />;
|
||||
}
|
||||
|
||||
function PluginResultRow({ result }: { result: UnifiedPluginResult }) {
|
||||
return <PluginListItem item={result.plugin} />;
|
||||
}
|
||||
|
||||
function UserResultRow({ result }: { result: UnifiedUserResult }) {
|
||||
const user = result.user as PublicUser;
|
||||
return <UserListItem user={user} />;
|
||||
}
|
||||
|
||||
+85
-31
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import {
|
||||
Eye,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Moon,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Star,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -257,6 +258,21 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star size={18} />
|
||||
Stars
|
||||
</CardTitle>
|
||||
<CardDescription>Review skills you've starred for quick access.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/stars">View stars</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit profile form */}
|
||||
<Card>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSave}>
|
||||
@@ -297,9 +313,7 @@ export function Settings() {
|
||||
<Settings2 size={18} />
|
||||
Customization
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Personalize your ClawHub experience
|
||||
</CardDescription>
|
||||
<CardDescription>Personalize your ClawHub experience</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="advanced-mode" className="text-sm text-[color:var(--ink-soft)]">
|
||||
@@ -316,7 +330,9 @@ export function Settings() {
|
||||
<CardContent className="space-y-6">
|
||||
{/* Theme Section */}
|
||||
<div className="space-y-3">
|
||||
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
|
||||
<Label id="theme" className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
Theme
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={themeMode === "light" ? "primary" : "ghost"}
|
||||
@@ -392,7 +408,7 @@ export function Settings() {
|
||||
{/* Layout Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">Layout</Label>
|
||||
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layout-density" className="text-xs text-[color:var(--ink-soft)]">
|
||||
@@ -400,7 +416,9 @@ export function Settings() {
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.layoutDensity}
|
||||
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
|
||||
onValueChange={(value) =>
|
||||
updatePreference("layoutDensity", value as LayoutDensity)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="layout-density">
|
||||
<SelectValue />
|
||||
@@ -428,7 +446,9 @@ export function Settings() {
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.listViewMode}
|
||||
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
|
||||
onValueChange={(value) =>
|
||||
updatePreference("listViewMode", value as ListViewMode)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="list-view">
|
||||
<SelectValue />
|
||||
@@ -453,7 +473,9 @@ export function Settings() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
|
||||
<Label htmlFor="show-descriptions" className="text-sm">
|
||||
Show descriptions
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-descriptions"
|
||||
checked={preferences.showDescriptions}
|
||||
@@ -461,7 +483,9 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
|
||||
<Label htmlFor="show-stats" className="text-sm">
|
||||
Show statistics
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-stats"
|
||||
checked={preferences.showStats}
|
||||
@@ -469,7 +493,9 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
|
||||
<Label htmlFor="show-tags" className="text-sm">
|
||||
Show tags
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-tags"
|
||||
checked={preferences.showTags}
|
||||
@@ -477,7 +503,9 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
|
||||
<Label htmlFor="sticky-header" className="text-sm">
|
||||
Sticky header
|
||||
</Label>
|
||||
<Switch
|
||||
id="sticky-header"
|
||||
checked={preferences.stickyHeader}
|
||||
@@ -496,15 +524,20 @@ export function Settings() {
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
Code & Content
|
||||
</Label>
|
||||
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code-font-size" className="text-xs text-[color:var(--ink-soft)]">
|
||||
<Label
|
||||
htmlFor="code-font-size"
|
||||
className="text-xs text-[color:var(--ink-soft)]"
|
||||
>
|
||||
Code font size
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.codeFontSize}
|
||||
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
|
||||
onValueChange={(value) =>
|
||||
updatePreference("codeFontSize", value as CodeFontSize)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="code-font-size">
|
||||
<SelectValue />
|
||||
@@ -518,20 +551,23 @@ export function Settings() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="animation-level" className="text-xs text-[color:var(--ink-soft)]">
|
||||
<Label
|
||||
htmlFor="animation-level"
|
||||
className="text-xs text-[color:var(--ink-soft)]"
|
||||
>
|
||||
Animation level
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.animationLevel}
|
||||
onValueChange={(value) => updatePreference("animationLevel", value as AnimationLevel)}
|
||||
onValueChange={(value) =>
|
||||
updatePreference("animationLevel", value as AnimationLevel)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="animation-level">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">
|
||||
Full
|
||||
</SelectItem>
|
||||
<SelectItem value="full">Full</SelectItem>
|
||||
<SelectItem value="reduced">Reduced</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -541,7 +577,9 @@ export function Settings() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
|
||||
<Label htmlFor="line-numbers" className="text-sm">
|
||||
Line numbers in code
|
||||
</Label>
|
||||
<Switch
|
||||
id="line-numbers"
|
||||
checked={preferences.lineNumbers}
|
||||
@@ -549,7 +587,9 @@ export function Settings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
|
||||
<Label htmlFor="word-wrap" className="text-sm">
|
||||
Word wrap in code
|
||||
</Label>
|
||||
<Switch
|
||||
id="word-wrap"
|
||||
checked={preferences.wordWrap}
|
||||
@@ -567,11 +607,13 @@ export function Settings() {
|
||||
<Eye size={14} className="text-[color:var(--accent)]" />
|
||||
Accessibility
|
||||
</Label>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="reduced-motion" className="text-sm">Reduced motion</Label>
|
||||
<Label htmlFor="reduced-motion" className="text-sm">
|
||||
Reduced motion
|
||||
</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -582,8 +624,12 @@ export function Settings() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="high-contrast" className="text-sm">High contrast</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Increase color contrast</p>
|
||||
<Label htmlFor="high-contrast" className="text-sm">
|
||||
High contrast
|
||||
</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
Increase color contrast
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="high-contrast"
|
||||
@@ -601,16 +647,22 @@ export function Settings() {
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
Experimental
|
||||
</Label>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="experimental-features" className="text-sm">Enable experimental features</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Try new features before they're released</p>
|
||||
<Label htmlFor="experimental-features" className="text-sm">
|
||||
Enable experimental features
|
||||
</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
Try new features before they're released
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="experimental-features"
|
||||
checked={preferences.experimentalFeatures}
|
||||
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
updatePreference("experimentalFeatures", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,7 +675,9 @@ export function Settings() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[color:var(--ink)]">Reset preferences</p>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Restore all settings to defaults</p>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">
|
||||
Restore all settings to defaults
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -64,14 +64,23 @@ function SoulsHoldingPage() {
|
||||
</div>
|
||||
<div className="skill-card-tags">
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/skills" search={{ q: undefined, sort: "downloads", dir: "desc", highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Browse Skills
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/users" search={{ q: undefined }}>
|
||||
Browse Users
|
||||
</Link>
|
||||
<Link to="/users">Browse Users</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { UserListItem } from "../../components/UserListItem";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { UserListItem } from "../../components/UserListItem";
|
||||
import { convexHttp } from "../../convex/client";
|
||||
import type { PublicUser } from "../../lib/publicUser";
|
||||
|
||||
type UserSearchState = {
|
||||
q?: string;
|
||||
};
|
||||
|
||||
type UsersLoaderResult = { items: PublicUser[]; total: number };
|
||||
|
||||
export const Route = createFileRoute("/users/")({
|
||||
validateSearch: (search): UserSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
}),
|
||||
component: UsersIndex,
|
||||
});
|
||||
|
||||
function UsersIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [result, setResult] = useState<UsersLoaderResult | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchUsers = useCallback(async (q?: string) => {
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await convexHttp.query(api.users.listPublic, {
|
||||
limit: 48,
|
||||
search: q,
|
||||
});
|
||||
setResult(data as UsersLoaderResult);
|
||||
} finally {
|
||||
@@ -41,9 +29,8 @@ function UsersIndex() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
void fetchUsers(search.q);
|
||||
}, [search.q, fetchUsers]);
|
||||
void fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const users = result?.items ?? [];
|
||||
|
||||
@@ -57,25 +44,6 @@ function UsersIndex() {
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
<form
|
||||
className="browse-page-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: {
|
||||
q: query.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="browse-search-input"
|
||||
placeholder="Search users..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
@@ -90,8 +58,7 @@ function UsersIndex() {
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">No users found</p>
|
||||
<p className="empty-state-body">Try a different handle or name.</p>
|
||||
<p className="empty-state-title">No users yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
|
||||
+97
-197
@@ -775,6 +775,11 @@ code {
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.navbar-search-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navbar-search:focus-within {
|
||||
border-color: var(--input-focus-border);
|
||||
box-shadow: 0 0 0 2px var(--input-focus-ring);
|
||||
@@ -807,6 +812,98 @@ code {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-section + .navbar-search-typeahead-section {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-heading {
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row:hover,
|
||||
.navbar-search-typeahead-row.is-active {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-row.is-footer {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-title,
|
||||
.navbar-search-typeahead-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-meta {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.navbar-search-typeahead-status {
|
||||
padding: 12px;
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.navbar-search-home {
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
@@ -8510,21 +8607,6 @@ code {
|
||||
}
|
||||
|
||||
/* Headline */
|
||||
.home-v2-hero-label {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--hv2-text-tertiary);
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition:
|
||||
color 0.2s,
|
||||
text-shadow 0.3s;
|
||||
}
|
||||
.home-v2-headline {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 700;
|
||||
@@ -8613,159 +8695,6 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ SLOT MACHINE EASTER EGG ═══ */
|
||||
.home-v2-hero-label:hover {
|
||||
color: var(--hv2-accent);
|
||||
}
|
||||
.home-v2-hero-label-active {
|
||||
color: var(--hv2-accent) !important;
|
||||
text-shadow: 0 0 12px rgba(212, 69, 58, 0.4);
|
||||
animation: home-v2-labelPulse 0.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes home-v2-labelPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-headline-slots {
|
||||
min-height: 1.15em;
|
||||
position: relative;
|
||||
}
|
||||
.home-v2-slot-reel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-weight: 800;
|
||||
color: var(--hv2-accent);
|
||||
position: relative;
|
||||
}
|
||||
.home-v2-slot-reel.spinning .home-v2-slot-word {
|
||||
animation: home-v2-slotBlur 0.12s steps(1) infinite;
|
||||
}
|
||||
.home-v2-slot-reel:not(.spinning) .home-v2-slot-word {
|
||||
animation: home-v2-slotLand 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
@keyframes home-v2-slotBlur {
|
||||
0% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
filter: blur(1px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes home-v2-slotLand {
|
||||
0% {
|
||||
transform: translateY(-8px) scale(1.1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
60% {
|
||||
transform: translateY(2px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-headline-jackpot {
|
||||
animation: home-v2-jackpot 0.5s ease-out;
|
||||
}
|
||||
@keyframes home-v2-jackpot {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.home-v2-headline-jackpot .home-v2-slot-word {
|
||||
color: #ffd93d !important;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 217, 61, 0.6),
|
||||
0 0 40px rgba(255, 217, 61, 0.3);
|
||||
}
|
||||
|
||||
/* ═══ HACK × 3 — Lobster / Aquatic Jackpot ═══ */
|
||||
.home-v2-headline-hack .home-v2-slot-word {
|
||||
color: #22d3ee !important;
|
||||
text-shadow:
|
||||
0 0 24px rgba(34, 211, 238, 0.6),
|
||||
0 0 48px rgba(6, 182, 212, 0.3),
|
||||
0 2px 8px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
.home-v2-headline-hack .home-v2-sep {
|
||||
border-color: #22d3ee;
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 8px rgba(34, 211, 238, 0.5);
|
||||
}
|
||||
.home-v2-hack-lobster {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 40px rgba(34, 211, 238, 0.5))
|
||||
drop-shadow(0 0 80px rgba(6, 182, 212, 0.25));
|
||||
animation: home-v2-lobsterReveal 1.2s 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes home-v2-lobsterReveal {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0) rotate(-30deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.18;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1) rotate(0deg);
|
||||
opacity: 0.12;
|
||||
}
|
||||
}
|
||||
|
||||
.home-v2-confetti {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-v2-sub {
|
||||
color: var(--hv2-text-tertiary);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 36px;
|
||||
max-width: 580px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.home-v2-sub-clear {
|
||||
color: var(--hv2-text-secondary);
|
||||
max-width: 820px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.home-v2-motto {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8830,16 +8759,6 @@ code {
|
||||
.home-v2-search-bar input::placeholder {
|
||||
color: var(--hv2-text-tertiary);
|
||||
}
|
||||
.home-v2-search-bar kbd {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--hv2-border);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
color: var(--hv2-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-v2-search-go {
|
||||
background: var(--hv2-accent-fill);
|
||||
color: var(--hv2-accent);
|
||||
@@ -8877,12 +8796,6 @@ code {
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.home-v2-suggestions-label {
|
||||
color: var(--hv2-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.home-v2-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8918,11 +8831,6 @@ code {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.home-v2-suggestions-label {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.home-v2-suggestion {
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
@@ -9714,11 +9622,6 @@ code {
|
||||
[data-theme-resolved="light"] .home-v2-search-bar input::placeholder {
|
||||
color: #9c8b7a;
|
||||
}
|
||||
[data-theme-resolved="light"] .home-v2-search-bar kbd {
|
||||
border-color: rgba(170, 125, 80, 0.2);
|
||||
color: #9c8b7a;
|
||||
}
|
||||
|
||||
/* Light — search button */
|
||||
[data-theme-resolved="light"] .home-v2-search-go {
|
||||
background: rgba(196, 58, 47, 0.06);
|
||||
@@ -9901,9 +9804,6 @@ code {
|
||||
.home-v2-search-go {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.home-v2-search-bar kbd {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.home-v2-c-icon {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user