From 8c86d6f570b98ce7028aa2b2a9347c906d9fbd57 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 7 May 2026 19:03:40 -0700 Subject: [PATCH] Fix plugin publish ownership visibility (#2073) * fix: clarify plugin publish ownership state * test: tolerate publish route migration in prod smoke * fix: reserve publish route collisions * fix: preflight package scope owner mismatches in CLI * fix: keep package scope validation server-side * docs: explain ClawHub publishing flow * fix: include publishing docs link in scope errors * fix: centralize docs links * fix: build docs links with URL * fix: shorten package scope docs hint --- .github/CODEOWNERS | 2 + .../codeql-frontend-publish-security.yml | 2 + convex/lib/packageRegistry.test.ts | 6 + convex/lib/packageRegistry.ts | 7 + convex/lib/publicRouteReservations.ts | 18 + convex/packages.public.test.ts | 215 ++++- convex/packages.ts | 56 +- convex/publishers.test.ts | 21 + convex/publishers.ts | 7 + convex/users.test.ts | 24 + convex/users.ts | 2 + docs/README.md | 13 +- docs/cli.md | 2 + docs/publishing.md | 85 ++ e2e/ci-smoke.pw.test.ts | 2 +- e2e/prod-http-smoke.e2e.test.ts | 2 +- e2e/publish-entry-workflows.pw.test.ts | 2 +- packages/schema/dist/docsLinks.d.ts | 12 + packages/schema/dist/docsLinks.js | 16 + packages/schema/dist/docsLinks.js.map | 1 + packages/schema/dist/index.d.ts | 1 + packages/schema/dist/index.js | 1 + packages/schema/dist/index.js.map | 2 +- packages/schema/dist/packages.d.ts | 8 + packages/schema/dist/packages.js | 21 + packages/schema/dist/packages.js.map | 2 +- packages/schema/src/docsLinks.ts | 19 + packages/schema/src/index.ts | 1 + packages/schema/src/packages.ts | 23 + packages/schema/src/schemas.test.ts | 26 + ...est.tsx => plugins-publish-route.test.tsx} | 83 +- src/__tests__/publish-redirect-routes.test.ts | 58 ++ ...test.tsx => skills-publish-route.test.tsx} | 2 +- src/components/Footer.test.tsx | 4 +- src/components/PackageSourceChooser.tsx | 7 +- src/components/SkillHeader.tsx | 2 +- src/lib/nav-items.ts | 4 +- src/routeTree.gen.ts | 42 + src/routes/about.tsx | 7 +- src/routes/dashboard.tsx | 6 +- src/routes/import.tsx | 2 +- src/routes/packages/new.tsx | 2 +- src/routes/plugins/index.tsx | 2 +- src/routes/plugins/new.tsx | 2 +- src/routes/plugins/publish.tsx | 422 ++++++++++ src/routes/publish-plugin.tsx | 380 +-------- src/routes/publish-skill.tsx | 739 +----------------- src/routes/skills/publish.tsx | 738 +++++++++++++++++ src/routes/upload.tsx | 2 +- 49 files changed, 1943 insertions(+), 1160 deletions(-) create mode 100644 convex/lib/publicRouteReservations.ts create mode 100644 docs/publishing.md create mode 100644 packages/schema/dist/docsLinks.d.ts create mode 100644 packages/schema/dist/docsLinks.js create mode 100644 packages/schema/dist/docsLinks.js.map create mode 100644 packages/schema/src/docsLinks.ts rename src/__tests__/{packages-publish-route.test.tsx => plugins-publish-route.test.tsx} (86%) create mode 100644 src/__tests__/publish-redirect-routes.test.ts rename src/__tests__/{upload.route.test.tsx => skills-publish-route.test.tsx} (99%) create mode 100644 src/routes/plugins/publish.tsx create mode 100644 src/routes/skills/publish.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c5de4d34..cba502fc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -69,8 +69,10 @@ /src/routes/admin.tsx @openclaw/openclaw-secops @BunsDev /src/routes/cli/auth.tsx @openclaw/openclaw-secops @BunsDev /src/routes/packages/new.tsx @openclaw/openclaw-secops @BunsDev +/src/routes/plugins/publish.tsx @openclaw/openclaw-secops @BunsDev /src/routes/publish-plugin.tsx @openclaw/openclaw-secops @BunsDev /src/routes/publish-skill.tsx @openclaw/openclaw-secops @BunsDev +/src/routes/skills/publish.tsx @openclaw/openclaw-secops @BunsDev /src/routes/upload.tsx @openclaw/openclaw-secops @BunsDev /src/routes/upload/ @openclaw/openclaw-secops @BunsDev /src/routes/$owner/$slug/security/ @openclaw/openclaw-secops @BunsDev diff --git a/.github/codeql/codeql-frontend-publish-security.yml b/.github/codeql/codeql-frontend-publish-security.yml index 9b409bc7..9fd683eb 100644 --- a/.github/codeql/codeql-frontend-publish-security.yml +++ b/.github/codeql/codeql-frontend-publish-security.yml @@ -32,8 +32,10 @@ paths: - src/routes/admin.tsx - src/routes/cli/auth.tsx - src/routes/packages/new.tsx + - src/routes/plugins/publish.tsx - src/routes/publish-plugin.tsx - src/routes/publish-skill.tsx + - src/routes/skills/publish.tsx - src/routes/upload.tsx - src/routes/upload - src/routes/$owner/$slug/security diff --git a/convex/lib/packageRegistry.test.ts b/convex/lib/packageRegistry.test.ts index 0e818b6a..a3af5612 100644 --- a/convex/lib/packageRegistry.test.ts +++ b/convex/lib/packageRegistry.test.ts @@ -5,6 +5,7 @@ import { ensurePluginNameMatchesPackage, extractBundlePluginArtifacts, extractCodePluginArtifacts, + normalizePackageName, summarizePackageForSearch, toConvexSafeJsonValue, tryNormalizePackageName, @@ -17,6 +18,11 @@ describe("packageRegistry", () => { expect(tryNormalizePackageName(" ")).toBeNull(); }); + it("reserves unscoped package names that collide with plugin routes", () => { + expect(() => normalizePackageName("publish")).toThrow("reserved for ClawHub routes"); + expect(normalizePackageName("@demo/publish")).toBe("@demo/publish"); + }); + it("extracts code plugin compatibility and capabilities", () => { const result = extractCodePluginArtifacts({ packageName: "@scope/demo-plugin", diff --git a/convex/lib/packageRegistry.ts b/convex/lib/packageRegistry.ts index 8fe07272..9f474f77 100644 --- a/convex/lib/packageRegistry.ts +++ b/convex/lib/packageRegistry.ts @@ -11,6 +11,10 @@ import type { import { ConvexError } from "convex/values"; import semver from "semver"; import type { ActionCtx } from "../_generated/server"; +import { + formatReservedUnscopedPackageNameMessage, + isReservedUnscopedPackageName, +} from "./publicRouteReservations"; import { getFrontmatterValue, parseFrontmatter, sanitizePath } from "./skills"; const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; @@ -166,6 +170,9 @@ export function normalizePackageName(name: string) { "Package name must be lowercase and npm-safe (example: @scope/name or plugin-name)", ); } + if (!normalized.startsWith("@") && isReservedUnscopedPackageName(normalized)) { + throw new ConvexError(formatReservedUnscopedPackageNameMessage(normalized)); + } return normalized; } diff --git a/convex/lib/publicRouteReservations.ts b/convex/lib/publicRouteReservations.ts new file mode 100644 index 00000000..5e503503 --- /dev/null +++ b/convex/lib/publicRouteReservations.ts @@ -0,0 +1,18 @@ +const RESERVED_PUBLIC_OWNER_HANDLES = new Set(["plugins", "skills"]); +const RESERVED_UNSCOPED_PACKAGE_NAMES = new Set(["publish"]); + +export function isReservedPublicOwnerHandle(handle: string | undefined | null) { + return Boolean(handle && RESERVED_PUBLIC_OWNER_HANDLES.has(handle.trim().toLowerCase())); +} + +export function isReservedUnscopedPackageName(name: string | undefined | null) { + return Boolean(name && RESERVED_UNSCOPED_PACKAGE_NAMES.has(name.trim().toLowerCase())); +} + +export function formatReservedPublicOwnerHandleMessage(handle: string) { + return `Handle "@${handle}" is reserved for ClawHub routes. Choose a different handle.`; +} + +export function formatReservedUnscopedPackageNameMessage(name: string) { + return `Package name "${name}" is reserved for ClawHub routes. Use a scoped name or choose a different package name.`; +} diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index e8c38939..dd02ac5a 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -888,24 +888,64 @@ function makeInsertReleaseCtx( existing: Record | null, priorReleases: Array> = [], recordsById: Record> = {}, + runtimePackages: Array> = [], ) { const patch = vi.fn(); - const insert = vi.fn().mockResolvedValueOnce("packageReleases:new"); + let insertedPackage: Record | null = null; + const insert = vi.fn(async (table: string, doc: Record) => { + if (table === "packages") { + insertedPackage = makePackageDoc({ + ...doc, + _id: "packages:new", + tags: {}, + latestReleaseId: undefined, + latestVersionSummary: undefined, + stats: { downloads: 0, installs: 0, stars: 0, versions: 0 }, + }); + return "packages:new"; + } + if (table === "packageReleases") return "packageReleases:new"; + return `${table}:new`; + }); return { patch, insert, db: { get: vi.fn(async (id: string) => { if (id in recordsById) return recordsById[id]; + if (id === "packages:new") return insertedPackage; if (id === "users:owner") return { _id: id, role: "user", trustedPublisher: false }; return null; }), query: vi.fn((table: string) => { if (table === "packages") { return { - withIndex: vi.fn((_indexName: string) => ({ - unique: vi.fn().mockResolvedValue(existing), - })), + withIndex: vi.fn( + ( + indexName: string, + buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + const filters = new Map(); + const query = { + eq(field: string, value: unknown) { + filters.set(field, value); + return query; + }, + }; + buildQuery?.(query); + if (indexName === "by_runtime_id") { + const runtimeId = filters.get("runtimeId"); + const matches = runtimePackages.filter((pkg) => pkg.runtimeId === runtimeId); + return { + collect: vi.fn().mockResolvedValue(matches), + unique: vi.fn().mockResolvedValue(matches[0] ?? null), + }; + } + return { + unique: vi.fn().mockResolvedValue(existing), + }; + }, + ), }; } if (table === "packageReleases") { @@ -1050,6 +1090,7 @@ function makeTransferPackageOwnerCtx(options?: { return { withIndex: vi.fn(() => ({ unique: vi.fn().mockResolvedValue(pkg), + collect: vi.fn().mockResolvedValue(pkg ? [pkg] : []), })), }; }), @@ -2142,7 +2183,7 @@ describe("packages public queries", () => { }); it("soft-deletes packages and active releases for the owner", async () => { - const { ctx, patch } = makeSoftDeletePackageCtx({ + const { ctx, insert, patch } = makeSoftDeletePackageCtx({ releases: [ makeReleaseDoc(), makeReleaseDoc({ @@ -2182,6 +2223,25 @@ describe("packages public queries", () => { updatedAt: expect.any(Number), }), ); + expect(insert).toHaveBeenCalledWith( + "auditLogs", + expect.objectContaining({ + actorUserId: "users:owner", + action: "package.delete", + targetType: "package", + targetId: "packages:demo", + metadata: expect.objectContaining({ + name: "demo-plugin", + normalizedName: "demo-plugin", + ownerUserId: "users:owner", + ownerPublisherId: undefined, + releaseCount: 1, + releaseIds: ["packageReleases:demo-1"], + source: "cli", + }), + createdAt: expect.any(Number), + }), + ); }); it("rejects non-owner package soft deletes without moderator access", async () => { @@ -2538,6 +2598,93 @@ describe("packages public queries", () => { ).rejects.toThrow("family changes are not allowed"); }); + it("rejects new releases on a soft-deleted package", async () => { + const ctx = makeInsertReleaseCtx(makePackageDoc({ softDeletedAt: 123 })); + + await expect( + insertReleaseInternalHandler(ctx, { + actorUserId: "users:owner", + ownerUserId: "users:owner", + name: "demo-plugin", + displayName: "Demo Plugin", + family: "code-plugin", + version: "1.0.1", + changelog: "try deleted package", + tags: ["latest"], + summary: "demo", + files: [], + integritySha256: "abc123", + }), + ).rejects.toThrow("Restore it before publishing another release"); + }); + + it("rejects package scopes that do not match the selected owner handle", async () => { + const runMutation = vi.fn(); + const ctx = { + runQuery: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + _id: "users:vintageayu", + githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000, + }) + .mockResolvedValueOnce({ + _id: "users:vintageayu", + role: "user", + githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000, + }), + runMutation, + scheduler: { + runAfter: vi.fn(), + }, + storage: { + get: vi.fn(), + }, + }; + + await expect( + publishPackageForUserInternalHandler(ctx as never, { + actorUserId: "users:vintageayu", + payload: { + name: "@openclaw/dronzer", + displayName: "Dronzer Controller", + ownerHandle: "vintageayu", + family: "code-plugin", + version: "1.0.0", + changelog: "init", + files: [], + }, + }), + ).rejects.toThrow('Package scope "@openclaw" must match selected owner "@vintageayu"'); + + expect(runMutation).not.toHaveBeenCalled(); + }); + + it("rejects unscoped package names that collide with publish routes", async () => { + const ctx = { + runQuery: vi.fn(), + runMutation: vi.fn(), + scheduler: { runAfter: vi.fn() }, + storage: { get: vi.fn() }, + }; + + await expect( + publishPackageForUserInternalHandler(ctx as never, { + actorUserId: "users:owner", + payload: { + name: "publish", + displayName: "Publish", + family: "code-plugin", + version: "1.0.0", + changelog: "init", + files: [], + }, + }), + ).rejects.toThrow('Package name "publish" is reserved for ClawHub routes'); + + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + it("rejects runtime id changes on an existing code plugin package", async () => { const ctx = makeInsertReleaseCtx(makePackageDoc({ runtimeId: "demo.plugin" })); @@ -2559,6 +2706,64 @@ describe("packages public queries", () => { ).rejects.toThrow("runtime id changes are not allowed"); }); + it("rejects plugin id collisions with active packages", async () => { + const ctx = makeInsertReleaseCtx(null, [], {}, [ + makePackageDoc({ + _id: "packages:claimed", + name: "claimed-plugin", + normalizedName: "claimed-plugin", + runtimeId: "dronzer", + softDeletedAt: undefined, + }), + ]); + + await expect( + insertReleaseInternalHandler(ctx, { + actorUserId: "users:owner", + ownerUserId: "users:owner", + name: "dronzerclaw", + displayName: "Dronzer Claw", + family: "code-plugin", + version: "1.0.0", + changelog: "init", + tags: ["latest"], + summary: "demo", + files: [], + integritySha256: "abc123", + runtimeId: "dronzer", + }), + ).rejects.toThrow('Plugin id "dronzer" is already claimed by another package'); + }); + + it("allows plugin ids held only by soft-deleted packages", async () => { + const ctx = makeInsertReleaseCtx(null, [], {}, [ + makePackageDoc({ + _id: "packages:deleted", + name: "@openclaw/dronzer", + normalizedName: "@openclaw/dronzer", + runtimeId: "dronzer", + softDeletedAt: 123, + }), + ]); + + await expect( + insertReleaseInternalHandler(ctx, { + actorUserId: "users:owner", + ownerUserId: "users:owner", + name: "dronzerclaw", + displayName: "Dronzer Claw", + family: "code-plugin", + version: "1.0.0", + changelog: "init", + tags: ["latest"], + summary: "demo", + files: [], + integritySha256: "abc123", + runtimeId: "dronzer", + }), + ).resolves.toMatchObject({ ok: true, packageId: "packages:new" }); + }); + it("promotes existing packages to official when publisher becomes trusted", async () => { const ctx = makeInsertReleaseCtx( makePackageDoc({ diff --git a/convex/packages.ts b/convex/packages.ts index f54b278c..2593ac1e 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -1,5 +1,6 @@ import { PackagePublishRequestSchema, + getPackageScopeOwnerMismatch, parseArk, validateOpenClawExternalCodePluginPackageContents, type PackageArtifactSummary, @@ -2104,7 +2105,11 @@ export const insertAuditLogInternal = internalMutation({ }, }); -async function softDeletePackageDoc(ctx: Pick, pkg: Doc<"packages">) { +async function softDeletePackageDoc( + ctx: Pick, + pkg: Doc<"packages">, + params: { actorUserId: Id<"users">; source: "cli" | "dashboard" }, +) { if (pkg.softDeletedAt) { return { ok: true as const, @@ -2120,10 +2125,12 @@ async function softDeletePackageDoc(ctx: Pick, pkg: Doc<"pack .withIndex("by_package", (q) => q.eq("packageId", pkg._id)) .collect(); let releaseCount = 0; + const deletedReleaseIds: Array> = []; for (const release of releases) { if (release.softDeletedAt) continue; await ctx.db.patch(release._id, { softDeletedAt: now }); releaseCount += 1; + deletedReleaseIds.push(release._id); } const packagePatch = { @@ -2135,6 +2142,22 @@ async function softDeletePackageDoc(ctx: Pick, pkg: Doc<"pack ...extractPackageDigestFields(pkg), ...packagePatch, }); + await ctx.db.insert("auditLogs", { + actorUserId: params.actorUserId, + action: "package.delete", + targetType: "package", + targetId: pkg._id, + metadata: { + name: pkg.name, + normalizedName: pkg.normalizedName, + ownerUserId: pkg.ownerUserId, + ownerPublisherId: pkg.ownerPublisherId, + releaseCount, + releaseIds: deletedReleaseIds, + source: params.source, + }, + createdAt: now, + }); return { ok: true as const, @@ -2170,7 +2193,7 @@ export const softDeletePackageInternal = internalMutation({ }); } - return await softDeletePackageDoc(ctx, pkg); + return await softDeletePackageDoc(ctx, pkg, { actorUserId: user._id, source: "cli" }); }, }); @@ -2194,7 +2217,7 @@ export const softDeletePackage = mutation({ }); } - return await softDeletePackageDoc(ctx, pkg); + return await softDeletePackageDoc(ctx, pkg, { actorUserId: user._id, source: "dashboard" }); }, }); @@ -3620,6 +3643,8 @@ async function publishPackageImpl( const actor = await runQueryRef | null>(ctx, internalRefs.users.getByIdInternal, { userId: actorUserId, }); + const ownerMismatch = getPackageScopeOwnerMismatch(name, payload.ownerHandle); + if (ownerMismatch) throw new ConvexError(ownerMismatch.message); const ownerHandle = payload.ownerHandle ?? inferOwnerHandleFromScopedPackageName(name); const ownerTarget = await runMutationRef<{ publisherId: Id<"publishers">; @@ -4164,11 +4189,14 @@ export const repairPackageIdentityInternal = internalMutation({ if (typeof args.nextRuntimeId === "string") { const nextRuntimeId = args.nextRuntimeId.trim(); if (!nextRuntimeId) throw new ConvexError("Runtime id required"); - const runtimeCollision = await ctx.db + const runtimeCollisions = await ctx.db .query("packages") .withIndex("by_runtime_id", (q) => q.eq("runtimeId", nextRuntimeId)) - .unique(); - if (runtimeCollision && runtimeCollision._id !== pkg._id && !runtimeCollision.softDeletedAt) { + .collect(); + const runtimeCollision = runtimeCollisions.find( + (candidate) => candidate._id !== pkg._id && !candidate.softDeletedAt, + ); + if (runtimeCollision) { throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`); } patch.runtimeId = nextRuntimeId; @@ -4277,6 +4305,12 @@ export const insertReleaseInternal = internalMutation({ const nextCapabilityTags = mergeArtifactCapabilityTags(args.capabilities?.capabilityTags, args); const existing = await getPackageByNormalizedName(ctx, normalizedName); const existingIsReservation = isReservedPackagePlaceholder(existing); + const nextNameLabel = typeof args.name === "string" ? args.name : ""; + if (existing?.softDeletedAt) { + throw new ConvexError( + `Package "${nextNameLabel}" was deleted. Restore it before publishing another release or choose a new package name.`, + ); + } const nextChannel = args.channel ?? (existing?.channel === "private" && !existingIsReservation @@ -4285,7 +4319,6 @@ export const insertReleaseInternal = internalMutation({ ? "official" : "community"); const nextIsOfficial = nextChannel === "official"; - const nextNameLabel = typeof args.name === "string" ? args.name : ""; const nextRuntimeIdLabel = typeof args.runtimeId === "string" ? args.runtimeId : ""; const nextVersionLabel = typeof args.version === "string" ? args.version : ""; if (existing) { @@ -4318,11 +4351,14 @@ export const insertReleaseInternal = internalMutation({ ); } if (args.family === "code-plugin" && args.runtimeId) { - const runtimeCollision = await ctx.db + const runtimeCollisions = await ctx.db .query("packages") .withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId)) - .unique(); - if (runtimeCollision && runtimeCollision._id !== existing?._id) { + .collect(); + const runtimeCollision = runtimeCollisions.find( + (candidate) => candidate._id !== existing?._id && !candidate.softDeletedAt, + ); + if (runtimeCollision) { throw new ConvexError( `Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`, ); diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index e4e67cc4..6ced919a 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -63,6 +63,27 @@ const updateProfileHandler = ( )._handler; describe("publishers membership controls", () => { + it("rejects org handles reserved for public routes", async () => { + const ctx = { + db: { + get: vi.fn(async (id: string) => (id === "users:admin" ? { _id: id, role: "admin" } : null)), + query: vi.fn(), + insert: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + replace: vi.fn(), + normalizeId: vi.fn(), + }, + }; + + await expect( + migrateLegacyPublisherHandleToOrgInternalHandler(ctx, { + actorUserId: "users:admin", + handle: "skills", + }), + ).rejects.toThrow('Handle "@skills" is reserved for ClawHub routes'); + }); + it("prevents admins from promoting members to owner", async () => { vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never); const ctx = { diff --git a/convex/publishers.ts b/convex/publishers.ts index e934368a..1a2d78af 100644 --- a/convex/publishers.ts +++ b/convex/publishers.ts @@ -14,6 +14,10 @@ import { isPublisherRoleAllowed, normalizePublisherHandle, } from "./lib/publishers"; +import { + formatReservedPublicOwnerHandleMessage, + isReservedPublicOwnerHandle, +} from "./lib/publicRouteReservations"; const PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; @@ -23,6 +27,9 @@ function validateHandle(rawHandle: string) { if (!PUBLISHER_HANDLE_PATTERN.test(handle)) { throw new ConvexError("Handle must be lowercase, url-safe, and 2-40 characters"); } + if (isReservedPublicOwnerHandle(handle)) { + throw new ConvexError(formatReservedPublicOwnerHandleMessage(handle)); + } return handle; } diff --git a/convex/users.test.ts b/convex/users.test.ts index 37c9c462..22316086 100644 --- a/convex/users.test.ts +++ b/convex/users.test.ts @@ -389,6 +389,30 @@ describe("ensureHandler", () => { }); }); + it("skips public route owner handles when deriving a handle", async () => { + const { ctx, patch } = makeCtx(); + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:skills", + user: { + _creationTime: 1, + handle: undefined, + displayName: undefined, + name: "skills", + email: undefined, + role: "user", + createdAt: 1, + }, + } as never); + + await ensureHandler(ctx); + + expect(patch).toHaveBeenCalledWith("users:skills", { + handle: "skills-2", + displayName: "skills-2", + updatedAt: expect.any(Number), + }); + }); + it("repairs an existing handle that is no longer claimable", async () => { const { ctx, patch, query } = makeCtx(); query.mockImplementation(((table: string) => { diff --git a/convex/users.ts b/convex/users.ts index 9d31b237..e6938d78 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -23,6 +23,7 @@ import { normalizeReservedHandle, upsertReservedHandleForRightfulOwner, } from "./lib/reservedHandles"; +import { isReservedPublicOwnerHandle } from "./lib/publicRouteReservations"; import { buildUserSearchResults } from "./lib/userSearch"; import { insertStatEvent } from "./skillStatEvents"; @@ -233,6 +234,7 @@ async function canUserClaimHandle( ) { const normalizedHandle = normalizeReservedHandle(handle); if (!normalizedHandle) return false; + if (isReservedPublicOwnerHandle(normalizedHandle)) return false; if (await isHandleReservedForAnotherUser(ctx, normalizedHandle, userId)) return false; const publisher = await getPublisherByHandle(ctx, normalizedHandle); diff --git a/docs/README.md b/docs/README.md index 32d68e3a..2021b16e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,12 +25,13 @@ Reading order: 1. `docs/clawhub.md`: public overview for discovery, install, publish, and trust. 2. `docs/quickstart.md`: product quickstart for users and publishers. 3. `docs/how-it-works.md`: listings, versions, installs, publishing, scans, and API access. -4. `docs/cli.md`: ClawHub CLI reference. -5. `docs/skill-format.md`: skill bundle metadata and package shape. -6. `docs/soul-format.md`: SOUL.md bundle format. -7. `docs/auth.md`: GitHub OAuth, API tokens, and CLI login. -8. `docs/telemetry.md`: what `clawhub sync` reports and how to opt out. -9. `docs/troubleshooting.md`: user-facing CLI, install, publish, sync, update, and API fixes. +4. `docs/publishing.md`: owner-scoped skill/plugin publishing flow. +5. `docs/cli.md`: ClawHub CLI reference. +6. `docs/skill-format.md`: skill bundle metadata and package shape. +7. `docs/soul-format.md`: SOUL.md bundle format. +8. `docs/auth.md`: GitHub OAuth, API tokens, and CLI login. +9. `docs/telemetry.md`: what `clawhub sync` reports and how to opt out. +10. `docs/troubleshooting.md`: user-facing CLI, install, publish, sync, update, and API fixes. Policy, API, and trust docs: diff --git a/docs/cli.md b/docs/cli.md index 8fab4878..2704a008 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -154,6 +154,7 @@ Stores your API token + cached registry URL. - Requires semver: `--version 1.2.3`. - `--owner ` publishes under an org/user publisher handle when the actor has publisher access. +- Owner and review behavior is explained in `docs/publishing.md`. - Publishing a skill means it is released under `MIT-0` on ClawHub. - Published skills are free to use, modify, and redistribute without attribution. - ClawHub does not support paid skills or per-skill pricing. @@ -421,6 +422,7 @@ clawhub package migration-status @openclaw/example-plugin - `--dry-run` previews the resolved publish payload without uploading. - `--json` emits machine-readable output for CI. - `--owner ` publishes under a user or org publisher handle when the actor has publisher access. +- Scoped package names must match the selected owner. See `docs/publishing.md`. - Existing flags (`--family`, `--name`, `--version`, `--source-repo`, `--source-commit`, `--source-ref`, `--source-path`) still work as overrides. - Private GitHub repos require `GITHUB_TOKEN`. diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 00000000..76a09dcf --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,85 @@ +--- +summary: "How ClawHub publishing works for skills, plugins, owners, scopes, releases, and review." +read_when: + - Publishing a skill or plugin + - Debugging owner or package scope errors + - Adding publish UI, CLI, or backend behavior +--- + +# Publishing + +ClawHub publishing is owner-scoped: every publish targets a publisher, and the +server decides whether the signed-in user is allowed to publish there. + +## Owners + +An owner is a ClawHub publisher handle, such as `@alice` or `@openclaw`. +Personal owners are created for users. Org owners can have multiple members. + +When you publish, you either use your personal owner or choose an org owner +where you have publisher access. + +## Skills + +Skills are published from a skill folder. The public page is: + +```text +https://clawhub.ai// +``` + +Example: + +```text +https://clawhub.ai/alice/review-helper +``` + +The publish request includes the selected owner, slug, version, changelog, and +files. The server verifies that the actor can publish as that owner before it +creates the release. + +## Plugins + +Plugins use npm-style package names. Scoped package names include the owner in +the first part of the name: + +```text +@owner/package-name +``` + +The scope must match the selected publish owner. If your package is named +`@openclaw/dronzer`, it can only be published as `@openclaw`. If you publish as +`@vintageayu`, rename the package to `@vintageayu/dronzer`. + +This prevents a package from claiming an org namespace that the publisher does +not control. + +## Release Flow + +1. The UI, CLI, or GitHub workflow gathers package metadata and files. +2. The publish request is sent to ClawHub with the selected owner. +3. The server validates owner permissions, package scope, package name, version, + file limits, and source metadata. +4. ClawHub stores the release and starts automated security checks. +5. New releases are hidden from normal install/download surfaces until review + and verification finish. + +If validation fails, the release is not created. + +## FAQ + +### Why does the package scope need to match the selected owner? + +If the package scope and selected owner do not match, ClawHub rejects the +publish: + +```text +Package scope "@openclaw" must match selected owner "@vintageayu". +Publish as "@openclaw" or rename this package to "@vintageayu/dronzer". +``` + +To fix it, either choose the owner named by the package scope, or rename the +package so the scope matches the owner you can publish as. + +This protects org namespaces. A package named `@openclaw/dronzer` claims the +`@openclaw` namespace, so only publishers with access to the `@openclaw` owner +can publish it. diff --git a/e2e/ci-smoke.pw.test.ts b/e2e/ci-smoke.pw.test.ts index e2a13c57..6bf20cae 100644 --- a/e2e/ci-smoke.pw.test.ts +++ b/e2e/ci-smoke.pw.test.ts @@ -27,7 +27,7 @@ test("signed-out publish entry renders", async ({ page }) => { const errors = trackRuntimeErrors(page); await page.goto("/upload", { waitUntil: "domcontentloaded" }); - await expect(page).toHaveURL(/\/publish-skill$/); + await expect(page).toHaveURL(/\/skills\/publish$/); await expect(page.getByText("Sign in to publish a skill")).toBeVisible(); await expectHealthyPage(page, errors); }); diff --git a/e2e/prod-http-smoke.e2e.test.ts b/e2e/prod-http-smoke.e2e.test.ts index 558caf55..5f12c2af 100644 --- a/e2e/prod-http-smoke.e2e.test.ts +++ b/e2e/prod-http-smoke.e2e.test.ts @@ -113,7 +113,7 @@ describe("prod http smoke", () => { expect(html).toContain("ClawHub"); expect(html).toContain('href="/skills"'); - expect(html).toContain('href="/publish-skill"'); + expect(html).toMatch(/href="\/(?:skills\/publish|publish-skill)"/); expect(html).not.toContain("Something went wrong!"); }); diff --git a/e2e/publish-entry-workflows.pw.test.ts b/e2e/publish-entry-workflows.pw.test.ts index 807c1671..e71f4ff2 100644 --- a/e2e/publish-entry-workflows.pw.test.ts +++ b/e2e/publish-entry-workflows.pw.test.ts @@ -5,7 +5,7 @@ test("upload shows signed-out publish gate", async ({ page }) => { const errors = trackRuntimeErrors(page); await page.goto("/upload", { waitUntil: "domcontentloaded" }); - await expect(page).toHaveURL(/\/publish-skill$/); + await expect(page).toHaveURL(/\/skills\/publish$/); await expect(page.getByText("Sign in to publish a skill")).toBeVisible(); await expectHealthyPage(page, errors); }); diff --git a/packages/schema/dist/docsLinks.d.ts b/packages/schema/dist/docsLinks.d.ts new file mode 100644 index 00000000..981ed151 --- /dev/null +++ b/packages/schema/dist/docsLinks.d.ts @@ -0,0 +1,12 @@ +export declare const OPENCLAW_DOCS_BASE_URL = "https://docs.openclaw.ai"; +export declare function openClawDocsUrl(path: string): string; +export declare const DocsLinks: { + readonly clawhub: { + readonly acceptableUsage: string; + readonly publishing: string; + readonly packageScopeFaq: string; + }; + readonly openclaw: { + readonly pluginPackageMetadata: string; + }; +}; diff --git a/packages/schema/dist/docsLinks.js b/packages/schema/dist/docsLinks.js new file mode 100644 index 00000000..2b216130 --- /dev/null +++ b/packages/schema/dist/docsLinks.js @@ -0,0 +1,16 @@ +export const OPENCLAW_DOCS_BASE_URL = "https://docs.openclaw.ai"; +export function openClawDocsUrl(path) { + const trimmed = path.trim().replace(/^\/+/, ""); + return new URL(trimmed, `${OPENCLAW_DOCS_BASE_URL}/`).href; +} +export const DocsLinks = { + clawhub: { + acceptableUsage: openClawDocsUrl("clawhub/acceptable-usage"), + publishing: openClawDocsUrl("clawhub/publishing"), + packageScopeFaq: openClawDocsUrl("clawhub/publishing#why-does-the-package-scope-need-to-match-the-selected-owner"), + }, + openclaw: { + pluginPackageMetadata: openClawDocsUrl("plugins/sdk-setup#package-metadata"), + }, +}; +//# sourceMappingURL=docsLinks.js.map \ No newline at end of file diff --git a/packages/schema/dist/docsLinks.js.map b/packages/schema/dist/docsLinks.js.map new file mode 100644 index 00000000..37f63e7b --- /dev/null +++ b/packages/schema/dist/docsLinks.js.map @@ -0,0 +1 @@ +{"version":3,"file":"docsLinks.js","sourceRoot":"","sources":["../src/docsLinks.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,sBAAsB,GAAG,0BAA0B,CAAC;AAEjE,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAChD,OAAO,IAAI,GAAG,CAAC,OAAO,EAAE,GAAG,sBAAsB,GAAG,CAAC,CAAC,IAAI,CAAC;AAC7D,CAAC;AAED,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,OAAO,EAAE;QACP,eAAe,EAAE,eAAe,CAAC,0BAA0B,CAAC;QAC5D,UAAU,EAAE,eAAe,CAAC,oBAAoB,CAAC;QACjD,eAAe,EAAE,eAAe,CAC9B,gFAAgF,CACjF;KACF;IACD,QAAQ,EAAE;QACR,qBAAqB,EAAE,eAAe,CAAC,oCAAoC,CAAC;KAC7E;CACO,CAAC"} \ No newline at end of file diff --git a/packages/schema/dist/index.d.ts b/packages/schema/dist/index.d.ts index c40af08e..1fae4c7f 100644 --- a/packages/schema/dist/index.d.ts +++ b/packages/schema/dist/index.d.ts @@ -1,5 +1,6 @@ export type { ArkValidator } from "./ark.js"; export { formatArkErrors, parseArk } from "./ark.js"; +export * from "./docsLinks.js"; export * from "./license.js"; export * from "./openclawContract.js"; export * from "./packages.js"; diff --git a/packages/schema/dist/index.js b/packages/schema/dist/index.js index e1758269..53e777c8 100644 --- a/packages/schema/dist/index.js +++ b/packages/schema/dist/index.js @@ -1,4 +1,5 @@ export { formatArkErrors, parseArk } from "./ark.js"; +export * from "./docsLinks.js"; export * from "./license.js"; export * from "./openclawContract.js"; export * from "./packages.js"; diff --git a/packages/schema/dist/index.js.map b/packages/schema/dist/index.js.map index a2517162..dc7b522b 100644 --- a/packages/schema/dist/index.js.map +++ b/packages/schema/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/schema/dist/packages.d.ts b/packages/schema/dist/packages.d.ts index 5cd0f3bd..b1e85340 100644 --- a/packages/schema/dist/packages.d.ts +++ b/packages/schema/dist/packages.d.ts @@ -1,4 +1,12 @@ import { type inferred } from "arktype"; +export declare function normalizePackageOwnerHandle(handle: string | null | undefined): string | undefined; +export declare function inferPackageNameScope(name: string): string | undefined; +export declare function getPackageScopeOwnerMismatch(name: string, ownerHandle: string | null | undefined): { + scope: string; + selectedOwner: string; + suggestedName: string; + message: string; +} | null; export declare const PackageFamilySchema: import("arktype/internal/variants/string.ts").StringType<"skill" | "code-plugin" | "bundle-plugin", {}>; export type PackageFamily = (typeof PackageFamilySchema)[inferred]; export declare const PackageChannelSchema: import("arktype/internal/variants/string.ts").StringType<"official" | "community" | "private", {}>; diff --git a/packages/schema/dist/packages.js b/packages/schema/dist/packages.js index 316c3e07..50a72dc6 100644 --- a/packages/schema/dist/packages.js +++ b/packages/schema/dist/packages.js @@ -1,5 +1,26 @@ import { type } from "arktype"; +import { DocsLinks } from "./docsLinks.js"; import { CliPublishFileSchema, PublishSourceSchema } from "./schemas.js"; +export function normalizePackageOwnerHandle(handle) { + const normalized = handle?.trim().replace(/^@+/, "").toLowerCase(); + return normalized || undefined; +} +export function inferPackageNameScope(name) { + return /^@([^/]+)\//.exec(name.trim().toLowerCase())?.[1]; +} +export function getPackageScopeOwnerMismatch(name, ownerHandle) { + const scope = inferPackageNameScope(name); + const selectedOwner = normalizePackageOwnerHandle(ownerHandle); + if (!scope || !selectedOwner || scope === selectedOwner) + return null; + const packageSlug = name.split("/").pop()?.trim() || "plugin-name"; + return { + scope, + selectedOwner, + suggestedName: `@${selectedOwner}/${packageSlug}`, + message: `Package scope "@${scope}" must match selected owner "@${selectedOwner}". Publish as "@${scope}" or rename this package to "@${selectedOwner}/${packageSlug}". More info: ${DocsLinks.clawhub.packageScopeFaq}`, + }; +} export const PackageFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"'); export const PackageChannelSchema = type('"official"|"community"|"private"'); export const PackageVerificationTierSchema = type('"structural"|"source-linked"|"provenance-verified"|"rebuild-verified"'); diff --git a/packages/schema/dist/packages.js.map b/packages/schema/dist/packages.js.map index fc9aea49..eb709bc0 100644 --- a/packages/schema/dist/packages.js.map +++ b/packages/schema/dist/packages.js.map @@ -1 +1 @@ -{"version":3,"file":"packages.js","sourceRoot":"","sources":["../src/packages.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"packages.js","sourceRoot":"","sources":["../src/packages.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/src/docsLinks.ts b/packages/schema/src/docsLinks.ts new file mode 100644 index 00000000..b201e744 --- /dev/null +++ b/packages/schema/src/docsLinks.ts @@ -0,0 +1,19 @@ +export const OPENCLAW_DOCS_BASE_URL = "https://docs.openclaw.ai"; + +export function openClawDocsUrl(path: string) { + const trimmed = path.trim().replace(/^\/+/, ""); + return new URL(trimmed, `${OPENCLAW_DOCS_BASE_URL}/`).href; +} + +export const DocsLinks = { + clawhub: { + acceptableUsage: openClawDocsUrl("clawhub/acceptable-usage"), + publishing: openClawDocsUrl("clawhub/publishing"), + packageScopeFaq: openClawDocsUrl( + "clawhub/publishing#why-does-the-package-scope-need-to-match-the-selected-owner", + ), + }, + openclaw: { + pluginPackageMetadata: openClawDocsUrl("plugins/sdk-setup#package-metadata"), + }, +} as const; diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index c40af08e..1fae4c7f 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,5 +1,6 @@ export type { ArkValidator } from "./ark.js"; export { formatArkErrors, parseArk } from "./ark.js"; +export * from "./docsLinks.js"; export * from "./license.js"; export * from "./openclawContract.js"; export * from "./packages.js"; diff --git a/packages/schema/src/packages.ts b/packages/schema/src/packages.ts index 2c0abb05..1121d9ac 100644 --- a/packages/schema/src/packages.ts +++ b/packages/schema/src/packages.ts @@ -1,6 +1,29 @@ import { type inferred, type } from "arktype"; +import { DocsLinks } from "./docsLinks.js"; import { CliPublishFileSchema, PublishSourceSchema } from "./schemas.js"; +export function normalizePackageOwnerHandle(handle: string | null | undefined) { + const normalized = handle?.trim().replace(/^@+/, "").toLowerCase(); + return normalized || undefined; +} + +export function inferPackageNameScope(name: string) { + return /^@([^/]+)\//.exec(name.trim().toLowerCase())?.[1]; +} + +export function getPackageScopeOwnerMismatch(name: string, ownerHandle: string | null | undefined) { + const scope = inferPackageNameScope(name); + const selectedOwner = normalizePackageOwnerHandle(ownerHandle); + if (!scope || !selectedOwner || scope === selectedOwner) return null; + const packageSlug = name.split("/").pop()?.trim() || "plugin-name"; + return { + scope, + selectedOwner, + suggestedName: `@${selectedOwner}/${packageSlug}`, + message: `Package scope "@${scope}" must match selected owner "@${selectedOwner}". Publish as "@${scope}" or rename this package to "@${selectedOwner}/${packageSlug}". More info: ${DocsLinks.clawhub.packageScopeFaq}`, + }; +} + export const PackageFamilySchema = type('"skill"|"code-plugin"|"bundle-plugin"'); export type PackageFamily = (typeof PackageFamilySchema)[inferred]; diff --git a/packages/schema/src/schemas.test.ts b/packages/schema/src/schemas.test.ts index 1c327c47..ad58f34a 100644 --- a/packages/schema/src/schemas.test.ts +++ b/packages/schema/src/schemas.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { parseArk } from "./ark"; +import { DocsLinks, openClawDocsUrl } from "./docsLinks"; +import { getPackageScopeOwnerMismatch, inferPackageNameScope } from "./packages"; import { ApiSearchResponseSchema, CliPublishRequestSchema, @@ -76,6 +78,30 @@ describe("clawhub-schema", () => { expect(payload.ownerHandle).toBe("openclaw"); }); + it("reports scoped package names that do not match the selected owner", () => { + expect(inferPackageNameScope("@openclaw/dronzer")).toBe("openclaw"); + expect(getPackageScopeOwnerMismatch("@openclaw/dronzer", "openclaw")).toBeNull(); + expect(getPackageScopeOwnerMismatch("@openclaw/dronzer", "@VintageAyu")).toEqual({ + scope: "openclaw", + selectedOwner: "vintageayu", + suggestedName: "@vintageayu/dronzer", + message: + `Package scope "@openclaw" must match selected owner "@vintageayu". Publish as "@openclaw" or rename this package to "@vintageayu/dronzer". More info: ${DocsLinks.clawhub.packageScopeFaq}`, + }); + }); + + it("builds OpenClaw docs URLs from normalized paths", () => { + expect(openClawDocsUrl("/clawhub/publishing")).toBe(DocsLinks.clawhub.publishing); + expect( + openClawDocsUrl( + "clawhub/publishing#why-does-the-package-scope-need-to-match-the-selected-owner", + ), + ).toBe(DocsLinks.clawhub.packageScopeFaq); + expect(openClawDocsUrl("plugins/sdk-setup#package-metadata")).toBe( + DocsLinks.openclaw.pluginPackageMetadata, + ); + }); + it("parses well-known config", () => { expect( parseArk(WellKnownConfigSchema, { registry: "https://example.convex.site" }, "WellKnown"), diff --git a/src/__tests__/packages-publish-route.test.tsx b/src/__tests__/plugins-publish-route.test.tsx similarity index 86% rename from src/__tests__/packages-publish-route.test.tsx rename to src/__tests__/plugins-publish-route.test.tsx index bed3fdc0..6467464d 100644 --- a/src/__tests__/packages-publish-route.test.tsx +++ b/src/__tests__/plugins-publish-route.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { DocsLinks } from "clawhub-schema"; import { createElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -19,24 +20,29 @@ vi.mock("@tanstack/react-router", () => ({ }), })); +vi.mock("@convex-dev/auth/react", () => ({ + useAuthActions: () => ({ signIn: vi.fn() }), +})); + const generateUploadUrl = vi.fn(); const publishRelease = vi.fn(); const fetchMock = vi.fn(); const useAuthStatusMock = vi.fn(); +const useQueryMock = vi.fn(); const originalFetch = globalThis.fetch; vi.mock("convex/react", () => ({ ConvexReactClient: class {}, useMutation: () => generateUploadUrl, useAction: () => publishRelease, - useQuery: () => undefined, + useQuery: () => useQueryMock(), })); vi.mock("../lib/useAuthStatus", () => ({ useAuthStatus: () => useAuthStatusMock(), })); -import { PublishPluginRoute, Route } from "../routes/publish-plugin"; +import { PublishPluginRoute, Route } from "../routes/plugins/publish"; function renderPublishRoute() { render(createElement(PublishPluginRoute as never)); @@ -81,12 +87,24 @@ describe("plugins publish route", () => { publishRelease.mockReset(); fetchMock.mockReset(); useAuthStatusMock.mockReset(); + useQueryMock.mockReset(); useAuthStatusMock.mockReturnValue({ isAuthenticated: true, isLoading: false, me: { _id: "users:1" }, }); + useQueryMock.mockReturnValue([ + { + publisher: { + _id: "publishers:vintageayu", + handle: "vintageayu", + displayName: "VintageAyu", + kind: "user", + }, + role: "owner", + }, + ]); generateUploadUrl.mockResolvedValue("https://upload.local"); publishRelease.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" }); fetchMock.mockImplementation(async (_url: string, init?: RequestInit) => ({ @@ -110,10 +128,27 @@ describe("plugins publish route", () => { }); }); - it("registers the publish form on /publish-plugin", () => { + it("registers the publish form on /plugins/publish", () => { expect(Route).toBeTruthy(); }); + it("requires sign-in before showing the plugin publish form", () => { + useAuthStatusMock.mockReturnValue({ + isAuthenticated: false, + isLoading: false, + me: null, + }); + + renderPublishRoute(); + + expect(screen.getByText("Sign in to publish a plugin")).toBeTruthy(); + expect( + screen.getByText("You need to be signed in to publish plugins on ClawHub."), + ).toBeTruthy(); + expect(screen.queryByText(/Upload plugin code to detect the package shape/i)).toBeNull(); + expect(screen.queryByPlaceholderText("Plugin name")).toBeNull(); + }); + it("keeps metadata inputs locked until plugin code is uploaded", () => { renderPublishRoute(); @@ -278,9 +313,47 @@ describe("plugins publish route", () => { expect(screen.getByText(/openclaw\.compat\.pluginApi/i)).toBeTruthy(); expect(screen.getByText(/openclaw\.build\.openclawVersion/i)).toBeTruthy(); const docsLink = screen.getByRole("link", { name: /Plugin Setup and Config/i }); - expect(docsLink.getAttribute("href")).toBe( - "https://docs.openclaw.ai/plugins/sdk-setup#package-metadata", + expect(docsLink.getAttribute("href")).toBe(DocsLinks.openclaw.pluginPackageMetadata); + expect(docsLink.getAttribute("target")).toBe("_blank"); + expect(docsLink.getAttribute("rel")).toBe("noopener noreferrer"); + expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull(); + expect(publishRelease).not.toHaveBeenCalled(); + }); + + it("blocks scoped package names that do not match the selected owner", async () => { + renderPublishRoute(); + + const packageJson = withRelativePath( + new File( + [ + makeCodePluginPackageJson({ + name: "@openclaw/dronzer", + displayName: "Dronzer Controller", + version: "1.0.0", + repository: "https://github.com/VintageAyu/dronzerclaw.git", + }), + ], + "package.json", + { type: "application/json" }, + ), + "dronzer/package.json", ); + const manifest = withRelativePath( + new File(['{"id":"dronzer"}'], "openclaw.plugin.json", { type: "application/json" }), + "dronzer/openclaw.plugin.json", + ); + + fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest] } }); + + await waitFor(() => { + expect(screen.getByDisplayValue("@openclaw/dronzer")).toBeTruthy(); + expect( + screen.getByText(/Package scope "@openclaw" must match selected owner "@vintageayu"/i), + ).toBeTruthy(); + }); + + const docsLink = screen.getByRole("link", { name: /Learn how publishing works/i }); + expect(docsLink.getAttribute("href")).toBe(DocsLinks.clawhub.packageScopeFaq); expect(docsLink.getAttribute("target")).toBe("_blank"); expect(docsLink.getAttribute("rel")).toBe("noopener noreferrer"); expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull(); diff --git a/src/__tests__/publish-redirect-routes.test.ts b/src/__tests__/publish-redirect-routes.test.ts new file mode 100644 index 00000000..1e94f38c --- /dev/null +++ b/src/__tests__/publish-redirect-routes.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const redirectMock = vi.fn((options: unknown) => ({ redirect: options })); + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: (path: string) => (config: unknown) => ({ __config: config, __path: path }), + redirect: (options: unknown) => redirectMock(options), +})); + +type LegacyRedirectRoute = { + __config: { + beforeLoad: (args: { search: Record<string, string | undefined> }) => never; + validateSearch: (search: Record<string, unknown>) => Record<string, string | undefined>; + }; + __path: string; +}; + +async function loadRoute(path: string): Promise<LegacyRedirectRoute> { + return ((await import(path)) as { Route: LegacyRedirectRoute }).Route; +} + +describe("legacy publish redirects", () => { + beforeEach(() => { + redirectMock.mockClear(); + }); + + it("redirects legacy plugin publish links to /plugins/publish", async () => { + const route = await loadRoute("../routes/publish-plugin"); + const search = route.__config.validateSearch({ + displayName: "Dronzer", + family: "code-plugin", + name: "@openclaw/dronzer", + nextVersion: "1.0.1", + ownerHandle: "vintageayu", + sourceRepo: "VintageAyu/dronzer", + ignored: "drop-me", + }); + + expect(route.__path).toBe("/publish-plugin"); + expect(() => route.__config.beforeLoad({ search })).toThrow(); + expect(redirectMock).toHaveBeenCalledWith({ + to: "/plugins/publish", + search, + }); + }); + + it("redirects legacy skill publish links to /skills/publish", async () => { + const route = await loadRoute("../routes/publish-skill"); + const search = route.__config.validateSearch({ updateSlug: "dronzer", ignored: "drop-me" }); + + expect(route.__path).toBe("/publish-skill"); + expect(() => route.__config.beforeLoad({ search })).toThrow(); + expect(redirectMock).toHaveBeenCalledWith({ + to: "/skills/publish", + search, + }); + }); +}); diff --git a/src/__tests__/upload.route.test.tsx b/src/__tests__/skills-publish-route.test.tsx similarity index 99% rename from src/__tests__/upload.route.test.tsx rename to src/__tests__/skills-publish-route.test.tsx index 526916dc..d2246b1d 100644 --- a/src/__tests__/upload.route.test.tsx +++ b/src/__tests__/skills-publish-route.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { strToU8, zipSync } from "fflate"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Upload } from "../routes/publish-skill"; +import { Upload } from "../routes/skills/publish"; vi.mock("@tanstack/react-router", () => ({ createFileRoute: () => (config: { component: unknown }) => config, diff --git a/src/components/Footer.test.tsx b/src/components/Footer.test.tsx index ade83203..9d9ee6f6 100644 --- a/src/components/Footer.test.tsx +++ b/src/components/Footer.test.tsx @@ -58,12 +58,12 @@ describe("Footer", () => { within(publish as HTMLElement) .getByRole("link", { name: "Publish Skill" }) .getAttribute("href"), - ).toBe("/publish-skill"); + ).toBe("/skills/publish"); expect( within(publish as HTMLElement) .getByRole("link", { name: "Publish Plugin" }) .getAttribute("href"), - ).toBe("/publish-plugin"); + ).toBe("/plugins/publish"); expect( within(community as HTMLElement) .getByRole("link", { name: "GitHub" }) diff --git a/src/components/PackageSourceChooser.tsx b/src/components/PackageSourceChooser.tsx index f6c4a170..6ef704dd 100644 --- a/src/components/PackageSourceChooser.tsx +++ b/src/components/PackageSourceChooser.tsx @@ -1,4 +1,4 @@ -import type { PackageCompatibility } from "clawhub-schema"; +import { DocsLinks, type PackageCompatibility } from "clawhub-schema"; import { Package } from "lucide-react"; import { useRef, useState } from "react"; import { formatPackageCompatibility } from "../lib/pluginPublishPrefill"; @@ -8,9 +8,6 @@ import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Card } from "./ui/card"; -const OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL = - "https://docs.openclaw.ai/plugins/sdk-setup#package-metadata"; - export function PackageSourceChooser(props: { files: File[]; totalBytes: number; @@ -161,7 +158,7 @@ export function PackageSourceChooser(props: { Missing required OpenClaw package metadata: {props.codePluginFieldIssues.join(", ")}. Add these fields to <code>package.json</code> before publishing. See{" "} <a - href={OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL} + href={DocsLinks.openclaw.pluginPackageMetadata} target="_blank" rel="noopener noreferrer" className="underline" diff --git a/src/components/SkillHeader.tsx b/src/components/SkillHeader.tsx index 438c2f87..88168155 100644 --- a/src/components/SkillHeader.tsx +++ b/src/components/SkillHeader.tsx @@ -213,7 +213,7 @@ export function SkillHeader({ ) : null} {canManage ? ( <Button asChild variant="outline" size="sm" className="skill-settings-link"> - <Link to="/publish-skill" search={{ updateSlug: skill.slug }}> + <Link to="/skills/publish" search={{ updateSlug: skill.slug }}> <Upload size={14} aria-hidden="true" /> New Version </Link> diff --git a/src/lib/nav-items.ts b/src/lib/nav-items.ts index 65e52787..4992f87d 100644 --- a/src/lib/nav-items.ts +++ b/src/lib/nav-items.ts @@ -163,13 +163,13 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [ { kind: "link", label: "Publish Skill", - to: "/publish-skill", + to: "/skills/publish", search: { updateSlug: undefined }, }, { kind: "link", label: "Publish Plugin", - to: "/publish-plugin", + to: "/plugins/publish", search: { ownerHandle: undefined, name: undefined, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 28566e14..2a29f457 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -29,6 +29,8 @@ import { Route as PluginsIndexRouteImport } from './routes/plugins/index' import { Route as PackagesIndexRouteImport } from './routes/packages/index' import { Route as UHandleRouteImport } from './routes/u/$handle' import { Route as SoulsSlugRouteImport } from './routes/souls/$slug' +import { Route as SkillsPublishRouteImport } from './routes/skills/publish' +import { Route as PluginsPublishRouteImport } from './routes/plugins/publish' import { Route as PluginsNewRouteImport } from './routes/plugins/new' import { Route as PluginsNameRouteImport } from './routes/plugins/$name' import { Route as PackagesNewRouteImport } from './routes/packages/new' @@ -144,6 +146,16 @@ const SoulsSlugRoute = SoulsSlugRouteImport.update({ path: '/souls/$slug', getParentRoute: () => rootRouteImport, } as any) +const SkillsPublishRoute = SkillsPublishRouteImport.update({ + id: '/skills/publish', + path: '/skills/publish', + getParentRoute: () => rootRouteImport, +} as any) +const PluginsPublishRoute = PluginsPublishRouteImport.update({ + id: '/plugins/publish', + path: '/plugins/publish', + getParentRoute: () => rootRouteImport, +} as any) const PluginsNewRoute = PluginsNewRouteImport.update({ id: '/plugins/new', path: '/plugins/new', @@ -240,6 +252,8 @@ export interface FileRoutesByFullPath { '/packages/new': typeof PackagesNewRoute '/plugins/$name': typeof PluginsNameRouteWithChildren '/plugins/new': typeof PluginsNewRoute + '/plugins/publish': typeof PluginsPublishRoute + '/skills/publish': typeof SkillsPublishRoute '/souls/$slug': typeof SoulsSlugRoute '/u/$handle': typeof UHandleRoute '/packages/': typeof PackagesIndexRoute @@ -276,6 +290,8 @@ export interface FileRoutesByTo { '/packages/new': typeof PackagesNewRoute '/plugins/$name': typeof PluginsNameRouteWithChildren '/plugins/new': typeof PluginsNewRoute + '/plugins/publish': typeof PluginsPublishRoute + '/skills/publish': typeof SkillsPublishRoute '/souls/$slug': typeof SoulsSlugRoute '/u/$handle': typeof UHandleRoute '/packages': typeof PackagesIndexRoute @@ -313,6 +329,8 @@ export interface FileRoutesById { '/packages/new': typeof PackagesNewRoute '/plugins/$name': typeof PluginsNameRouteWithChildren '/plugins/new': typeof PluginsNewRoute + '/plugins/publish': typeof PluginsPublishRoute + '/skills/publish': typeof SkillsPublishRoute '/souls/$slug': typeof SoulsSlugRoute '/u/$handle': typeof UHandleRoute '/packages/': typeof PackagesIndexRoute @@ -351,6 +369,8 @@ export interface FileRouteTypes { | '/packages/new' | '/plugins/$name' | '/plugins/new' + | '/plugins/publish' + | '/skills/publish' | '/souls/$slug' | '/u/$handle' | '/packages/' @@ -387,6 +407,8 @@ export interface FileRouteTypes { | '/packages/new' | '/plugins/$name' | '/plugins/new' + | '/plugins/publish' + | '/skills/publish' | '/souls/$slug' | '/u/$handle' | '/packages' @@ -423,6 +445,8 @@ export interface FileRouteTypes { | '/packages/new' | '/plugins/$name' | '/plugins/new' + | '/plugins/publish' + | '/skills/publish' | '/souls/$slug' | '/u/$handle' | '/packages/' @@ -460,6 +484,8 @@ export interface RootRouteChildren { PackagesNewRoute: typeof PackagesNewRoute PluginsNameRoute: typeof PluginsNameRouteWithChildren PluginsNewRoute: typeof PluginsNewRoute + PluginsPublishRoute: typeof PluginsPublishRoute + SkillsPublishRoute: typeof SkillsPublishRoute SoulsSlugRoute: typeof SoulsSlugRoute UHandleRoute: typeof UHandleRoute PackagesIndexRoute: typeof PackagesIndexRoute @@ -613,6 +639,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SoulsSlugRouteImport parentRoute: typeof rootRouteImport } + '/skills/publish': { + id: '/skills/publish' + path: '/skills/publish' + fullPath: '/skills/publish' + preLoaderRoute: typeof SkillsPublishRouteImport + parentRoute: typeof rootRouteImport + } + '/plugins/publish': { + id: '/plugins/publish' + path: '/plugins/publish' + fullPath: '/plugins/publish' + preLoaderRoute: typeof PluginsPublishRouteImport + parentRoute: typeof rootRouteImport + } '/plugins/new': { id: '/plugins/new' path: '/plugins/new' @@ -773,6 +813,8 @@ const rootRouteChildren: RootRouteChildren = { PackagesNewRoute: PackagesNewRoute, PluginsNameRoute: PluginsNameRouteWithChildren, PluginsNewRoute: PluginsNewRoute, + PluginsPublishRoute: PluginsPublishRoute, + SkillsPublishRoute: SkillsPublishRoute, SoulsSlugRoute: SoulsSlugRoute, UHandleRoute: UHandleRoute, PackagesIndexRoute: PackagesIndexRoute, diff --git a/src/routes/about.tsx b/src/routes/about.tsx index fbc0fe3c..de49697e 100644 --- a/src/routes/about.tsx +++ b/src/routes/about.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { DocsLinks } from "clawhub-schema"; import type { LucideIcon } from "lucide-react"; import { Banknote, @@ -263,11 +264,7 @@ function AboutPage() { <Link to="/skills">Browse Skills</Link> </Button> <Button asChild> - <a - href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md" - target="_blank" - rel="noreferrer" - > + <a href={DocsLinks.clawhub.acceptableUsage} target="_blank" rel="noreferrer"> Reviewer Doc </a> </Button> diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index b2aac26e..c8c76f13 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -204,7 +204,7 @@ export function Dashboard() { </p> <div className="flex gap-3 justify-center"> <Button asChild variant="primary"> - <Link to="/publish-skill" search={{ updateSlug: undefined }}> + <Link to="/skills/publish" search={{ updateSlug: undefined }}> Publish a Skill </Link> </Button> @@ -245,7 +245,7 @@ export function Dashboard() { <div className="dashboard-section-header"> <h2 className="dashboard-collection-title">Skills</h2> <Button asChild size="sm" className="dashboard-section-action"> - <Link to="/publish-skill" search={{ updateSlug: undefined }}> + <Link to="/skills/publish" search={{ updateSlug: undefined }}> <Plus className="h-4 w-4" aria-hidden="true" /> New Skill </Link> @@ -284,7 +284,7 @@ export function Dashboard() { <div className="dashboard-section-header"> <h2 className="dashboard-collection-title">Plugins</h2> <Button asChild size="sm" className="dashboard-section-action"> - <Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}> + <Link to="/plugins/publish" search={{ ...emptyPluginPublishSearch, ownerHandle }}> <Plus className="h-4 w-4" aria-hidden="true" /> New Plugin </Link> diff --git a/src/routes/import.tsx b/src/routes/import.tsx index 658ef2ae..3ee8aadc 100644 --- a/src/routes/import.tsx +++ b/src/routes/import.tsx @@ -251,7 +251,7 @@ export function ImportGitHub() { <Badge variant="accent" className="mt-3 w-fit"> Skill-only import. Plugins are not supported here. Use{" "} <Link - to="/publish-plugin" + to="/plugins/publish" search={{ ownerHandle: undefined, name: undefined, diff --git a/src/routes/packages/new.tsx b/src/routes/packages/new.tsx index 9c5f37dd..b4a6e7de 100644 --- a/src/routes/packages/new.tsx +++ b/src/routes/packages/new.tsx @@ -3,7 +3,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/packages/new")({ beforeLoad: () => { throw redirect({ - to: "/publish-plugin", + to: "/plugins/publish", search: { ownerHandle: undefined, name: undefined, diff --git a/src/routes/plugins/index.tsx b/src/routes/plugins/index.tsx index f5f39dad..058d60da 100644 --- a/src/routes/plugins/index.tsx +++ b/src/routes/plugins/index.tsx @@ -186,7 +186,7 @@ function PluginsIndex() { <div className="browse-page-actions"> <Button asChild variant="primary"> <Link - to="/publish-plugin" + to="/plugins/publish" search={{ ownerHandle: undefined, name: undefined, diff --git a/src/routes/plugins/new.tsx b/src/routes/plugins/new.tsx index e8267cb5..a69aecfc 100644 --- a/src/routes/plugins/new.tsx +++ b/src/routes/plugins/new.tsx @@ -11,7 +11,7 @@ export const Route = createFileRoute("/plugins/new")({ }), beforeLoad: ({ search }) => { throw redirect({ - to: "/publish-plugin", + to: "/plugins/publish", search, }); }, diff --git a/src/routes/plugins/publish.tsx b/src/routes/plugins/publish.tsx new file mode 100644 index 00000000..74cc7396 --- /dev/null +++ b/src/routes/plugins/publish.tsx @@ -0,0 +1,422 @@ +import { createFileRoute, useSearch } from "@tanstack/react-router"; +import { + DocsLinks, + getPackageScopeOwnerMismatch, + type PackageCompatibility, +} from "clawhub-schema"; +import { useAction, useMutation, useQuery } from "convex/react"; +import { startTransition, useEffect, useMemo, useState } from "react"; +import semver from "semver"; +import { toast } from "sonner"; +import { api } from "../../../convex/_generated/api"; +import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../../convex/lib/publishLimits"; +import { EmptyState } from "../../components/EmptyState"; +import { Container } from "../../components/layout/Container"; +import { PackageSourceChooser } from "../../components/PackageSourceChooser"; +import { SignInButton } from "../../components/SignInButton"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { Card } from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { Textarea } from "../../components/ui/textarea"; +import { + buildPackageUploadEntries, + filterIgnoredPackageFiles, + normalizePackageUploadFiles, +} from "../../lib/packageUpload"; +import { derivePluginPrefill, listPrefilledFields } from "../../lib/pluginPublishPrefill"; +import { expandFilesWithReport } from "../../lib/uploadFiles"; +import { useAuthStatus } from "../../lib/useAuthStatus"; +import { formatPublishError, hashFile, uploadFile } from "../upload/-utils"; + +export const Route = createFileRoute("/plugins/publish")({ + validateSearch: (search) => ({ + ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined, + name: typeof search.name === "string" ? search.name : undefined, + displayName: typeof search.displayName === "string" ? search.displayName : undefined, + family: search.family === "code-plugin" ? search.family : undefined, + nextVersion: typeof search.nextVersion === "string" ? search.nextVersion : undefined, + sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined, + }), + component: PublishPluginRoute, +}); + +const apiRefs = api as unknown as { + packages: { + publishRelease: unknown; + }; +}; + +const SHOW_CLAWPACK_ONBOARDING_BANNER = false; +export function PublishPluginRoute() { + const search = useSearch({ from: "/plugins/publish" }); + const { isAuthenticated } = useAuthStatus(); + const publishers = useQuery(api.publishers.listMine) as + | Array<{ + publisher: { + _id: string; + handle: string; + displayName: string; + kind: "user" | "org"; + }; + role: "owner" | "admin" | "publisher"; + }> + | undefined; + const generateUploadUrl = useMutation(api.uploads.generateUploadUrl); + const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (args: { + payload: unknown; + }) => Promise<unknown>; + const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">("code-plugin"); + const [name, setName] = useState(search.name ?? ""); + const [displayName, setDisplayName] = useState(search.displayName ?? ""); + const [ownerHandle, setOwnerHandle] = useState(search.ownerHandle ?? ""); + const [version, setVersion] = useState(search.nextVersion ?? "0.1.0"); + const [changelog, setChangelog] = useState(""); + const [sourceRepo, setSourceRepo] = useState(search.sourceRepo ?? ""); + const [sourceCommit, setSourceCommit] = useState(""); + const [sourceRef, setSourceRef] = useState(""); + const [sourcePath, setSourcePath] = useState("."); + const [bundleFormat, setBundleFormat] = useState(""); + const [hostTargets, setHostTargets] = useState(""); + const [files, setFiles] = useState<File[]>([]); + const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]); + const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]); + const [codePluginFieldIssues, setCodePluginFieldIssues] = useState<string[]>([]); + const [codePluginCompatibility, setCodePluginCompatibility] = + useState<PackageCompatibility | null>(null); + const [status, setStatus] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + + const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]); + const normalizedPaths = useMemo( + () => normalizePackageUploadFiles(files).map((entry) => entry.path), + [files], + ); + const normalizedPathSet = useMemo( + () => new Set(normalizedPaths.map((path) => path.toLowerCase())), + [normalizedPaths], + ); + const oversizedFiles = useMemo( + () => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES), + [files], + ); + const oversizedFileNames = useMemo( + () => oversizedFiles.slice(0, 3).map((file) => file.name), + [oversizedFiles], + ); + const validationError = + oversizedFiles.length > 0 + ? `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}` + : totalBytes > MAX_PUBLISH_TOTAL_BYTES + ? "Total file size exceeds 50MB." + : null; + const isMetadataLocked = files.length === 0; + const isSubmitting = status !== null; + const metadataDisabled = isMetadataLocked || isSubmitting; + const ownerScopeError = useMemo(() => { + return getPackageScopeOwnerMismatch(name, ownerHandle)?.message ?? null; + }, [name, ownerHandle]); + + const onPickFiles = async (selected: File[]) => { + const expanded = await expandFilesWithReport(selected, { + includeBinaryArchiveFiles: true, + }); + const filtered = await filterIgnoredPackageFiles(expanded.files); + const normalized = normalizePackageUploadFiles(filtered.files); + const nextIgnoredPaths = [ + ...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths]), + ]; + setFiles(filtered.files); + setIgnoredPaths(nextIgnoredPaths); + setError(null); + const prefill = await derivePluginPrefill(normalized); + setDetectedPrefillFields(listPrefilledFields(prefill)); + setCodePluginFieldIssues(prefill.missingRequiredFields ?? []); + setCodePluginCompatibility(prefill.compatibility ?? null); + if (prefill.family === "code-plugin") setFamily(prefill.family); + if (prefill.name) setName(prefill.name); + if (prefill.displayName) setDisplayName(prefill.displayName); + if (prefill.version) setVersion(prefill.version); + if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo); + if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat); + if (prefill.hostTargets) setHostTargets(prefill.hostTargets); + }; + + useEffect(() => { + if (ownerHandle) return; + const personal = + publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0]; + if (personal?.publisher.handle) { + setOwnerHandle(personal.publisher.handle); + } + }, [ownerHandle, publishers]); + + if (!isAuthenticated) { + return ( + <main className="py-10"> + <Container size="narrow"> + <EmptyState + title="Sign in to publish a plugin" + description="You need to be signed in to publish plugins on ClawHub." + > + <SignInButton /> + </EmptyState> + </Container> + </main> + ); + } + + return ( + <main className="py-10"> + <Container> + <header className="mb-6"> + <h1 className="mb-2 font-display text-2xl font-bold text-[color:var(--ink)]"> + {search.name ? "Publish Plugin Release" : "Publish Plugin"} + </h1> + <p className="text-sm text-[color:var(--ink-soft)]"> + Publish a native code plugin release. + </p> + <p className="text-sm text-[color:var(--ink-soft)]"> + New releases stay private until automated security checks and verification finish. + </p> + {search.name ? ( + <p className="text-sm text-[color:var(--ink-soft)]"> + Prefilled for {search.displayName ?? search.name} + {search.nextVersion && semver.valid(search.nextVersion) + ? ` \u00b7 suggested ${search.nextVersion}` + : ""} + </p> + ) : null} + </header> + + {SHOW_CLAWPACK_ONBOARDING_BANNER ? ( + <Card className="mb-5 border-[rgba(255,107,74,0.3)] bg-[rgba(255,107,74,0.06)]"> + <p className="text-sm font-medium text-[color:var(--ink)]"> + ClawPack publishing is moving to npm-pack .tgz uploads. + </p> + <p className="mt-1 text-sm text-[color:var(--ink-soft)]"> + Use the CLI for exact ClawPack bytes while the web uploader remains on the legacy + compatibility path. + </p> + </Card> + ) : null} + + <PackageSourceChooser + files={files} + totalBytes={totalBytes} + normalizedPaths={normalizedPaths} + normalizedPathSet={normalizedPathSet} + ignoredPaths={ignoredPaths} + detectedPrefillFields={detectedPrefillFields} + family={family} + validationError={validationError} + codePluginFieldIssues={codePluginFieldIssues} + codePluginCompatibility={codePluginCompatibility} + onPickFiles={onPickFiles} + /> + + <Card + className={isMetadataLocked ? "pointer-events-none opacity-60" : ""} + aria-disabled={isMetadataLocked} + > + <div className="flex flex-col gap-3"> + <p className="text-sm text-[color:var(--ink-soft)]"> + {isMetadataLocked + ? "Upload plugin code to detect the package shape and unlock the release form." + : "Metadata detected and prefilled. Review it, then fill any missing release details."} + </p> + <select + className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-sm text-[color:var(--ink)] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]" + value={family} + disabled={metadataDisabled} + onChange={(event) => setFamily(event.target.value as never)} + > + <option value="code-plugin">Code plugin</option> + </select> + <Input + placeholder="Plugin name" + value={name} + disabled={metadataDisabled} + onChange={(event) => setName(event.target.value)} + /> + {ownerScopeError ? ( + <Badge variant="accent"> + <span>{ownerScopeError}</span> + <a + href={DocsLinks.clawhub.packageScopeFaq} + target="_blank" + rel="noopener noreferrer" + className="underline underline-offset-2" + > + Learn how publishing works + </a> + </Badge> + ) : null} + <Input + placeholder="Display name" + value={displayName} + disabled={metadataDisabled} + onChange={(event) => setDisplayName(event.target.value)} + /> + <select + className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-sm text-[color:var(--ink)] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]" + value={ownerHandle} + disabled={metadataDisabled} + onChange={(event) => setOwnerHandle(event.target.value)} + > + {(publishers ?? []).map((entry) => ( + <option key={entry.publisher._id} value={entry.publisher.handle}> + @{entry.publisher.handle} · {entry.publisher.displayName} + </option> + ))} + </select> + <Input + placeholder="Version" + value={version} + disabled={metadataDisabled} + onChange={(event) => setVersion(event.target.value)} + /> + <Textarea + placeholder="Changelog" + rows={4} + value={changelog} + disabled={metadataDisabled} + onChange={(event) => setChangelog(event.target.value)} + /> + <Input + placeholder="Source repo (owner/repo)" + value={sourceRepo} + disabled={metadataDisabled} + onChange={(event) => setSourceRepo(event.target.value)} + /> + <Input + placeholder="Source commit" + value={sourceCommit} + disabled={metadataDisabled} + onChange={(event) => setSourceCommit(event.target.value)} + /> + <Input + placeholder="Source ref (tag or branch)" + value={sourceRef} + disabled={metadataDisabled} + onChange={(event) => setSourceRef(event.target.value)} + /> + <Input + placeholder="Source path" + value={sourcePath} + disabled={metadataDisabled} + onChange={(event) => setSourcePath(event.target.value)} + /> + {family === "bundle-plugin" ? ( + <> + <Input + placeholder="Bundle format" + value={bundleFormat} + disabled={metadataDisabled} + onChange={(event) => setBundleFormat(event.target.value)} + /> + <Input + placeholder="Host targets (comma separated)" + value={hostTargets} + disabled={metadataDisabled} + onChange={(event) => setHostTargets(event.target.value)} + /> + </> + ) : null} + <Button + variant="primary" + disabled={ + !isAuthenticated || + isMetadataLocked || + !name.trim() || + !version.trim() || + files.length === 0 || + Boolean(validationError) || + Boolean(ownerScopeError) || + isSubmitting || + (family === "code-plugin" && + (!sourceRepo.trim() || !sourceCommit.trim() || codePluginFieldIssues.length > 0)) + } + onClick={() => { + startTransition(() => { + void (async () => { + try { + if (validationError) { + toast.error(validationError); + return; + } + if (ownerScopeError) { + toast.error(ownerScopeError); + return; + } + if (family === "code-plugin" && codePluginFieldIssues.length > 0) { + toast.error( + `Missing required OpenClaw package metadata: ${codePluginFieldIssues.join(", ")}`, + ); + return; + } + setStatus("Uploading files..."); + setError(null); + const uploaded = await buildPackageUploadEntries(files, { + generateUploadUrl, + hashFile, + uploadFile, + }); + setStatus("Publishing release..."); + await publishRelease({ + payload: { + name: name.trim(), + displayName: displayName.trim() || undefined, + ownerHandle: ownerHandle || undefined, + family, + version: version.trim(), + changelog: changelog.trim(), + ...(sourceRepo.trim() && sourceCommit.trim() + ? { + source: { + kind: "github" as const, + repo: sourceRepo.trim(), + url: sourceRepo.trim().startsWith("http") + ? sourceRepo.trim() + : `https://github.com/${sourceRepo.trim().replace(/^\/+|\/+$/g, "")}`, + ref: sourceRef.trim() || sourceCommit.trim(), + commit: sourceCommit.trim(), + path: sourcePath.trim() || ".", + importedAt: Date.now(), + }, + } + : {}), + ...(family === "bundle-plugin" + ? { + bundle: { + format: bundleFormat.trim() || undefined, + hostTargets: hostTargets + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + }, + } + : {}), + files: uploaded, + }, + }); + setStatus( + "Published. Pending security checks and verification before public listing.", + ); + } catch (publishError) { + toast.error(formatPublishError(publishError)); + setStatus(null); + } + })(); + }); + }} + > + {status ?? "Publish"} + </Button> + {error ? <Badge variant="accent">{error}</Badge> : null} + </div> + </Card> + </Container> + </main> + ); +} diff --git a/src/routes/publish-plugin.tsx b/src/routes/publish-plugin.tsx index ceb5c7dc..71b89866 100644 --- a/src/routes/publish-plugin.tsx +++ b/src/routes/publish-plugin.tsx @@ -1,27 +1,4 @@ -import { createFileRoute, useSearch } from "@tanstack/react-router"; -import type { PackageCompatibility } from "clawhub-schema"; -import { useAction, useMutation, useQuery } from "convex/react"; -import { startTransition, useEffect, useMemo, useState } from "react"; -import semver from "semver"; -import { toast } from "sonner"; -import { api } from "../../convex/_generated/api"; -import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits"; -import { Container } from "../components/layout/Container"; -import { PackageSourceChooser } from "../components/PackageSourceChooser"; -import { Badge } from "../components/ui/badge"; -import { Button } from "../components/ui/button"; -import { Card } from "../components/ui/card"; -import { Input } from "../components/ui/input"; -import { Textarea } from "../components/ui/textarea"; -import { - buildPackageUploadEntries, - filterIgnoredPackageFiles, - normalizePackageUploadFiles, -} from "../lib/packageUpload"; -import { derivePluginPrefill, listPrefilledFields } from "../lib/pluginPublishPrefill"; -import { expandFilesWithReport } from "../lib/uploadFiles"; -import { useAuthStatus } from "../lib/useAuthStatus"; -import { formatPublishError, hashFile, uploadFile } from "./upload/-utils"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/publish-plugin")({ validateSearch: (search) => ({ @@ -32,353 +9,10 @@ export const Route = createFileRoute("/publish-plugin")({ nextVersion: typeof search.nextVersion === "string" ? search.nextVersion : undefined, sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined, }), - component: PublishPluginRoute, -}); - -const apiRefs = api as unknown as { - packages: { - publishRelease: unknown; - }; -}; - -const SHOW_CLAWPACK_ONBOARDING_BANNER = false; - -export function PublishPluginRoute() { - const search = useSearch({ from: "/publish-plugin" }); - const { isAuthenticated } = useAuthStatus(); - const publishers = useQuery(api.publishers.listMine) as - | Array<{ - publisher: { - _id: string; - handle: string; - displayName: string; - kind: "user" | "org"; - }; - role: "owner" | "admin" | "publisher"; - }> - | undefined; - const generateUploadUrl = useMutation(api.uploads.generateUploadUrl); - const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (args: { - payload: unknown; - }) => Promise<unknown>; - const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">("code-plugin"); - const [name, setName] = useState(search.name ?? ""); - const [displayName, setDisplayName] = useState(search.displayName ?? ""); - const [ownerHandle, setOwnerHandle] = useState(search.ownerHandle ?? ""); - const [version, setVersion] = useState(search.nextVersion ?? "0.1.0"); - const [changelog, setChangelog] = useState(""); - const [sourceRepo, setSourceRepo] = useState(search.sourceRepo ?? ""); - const [sourceCommit, setSourceCommit] = useState(""); - const [sourceRef, setSourceRef] = useState(""); - const [sourcePath, setSourcePath] = useState("."); - const [bundleFormat, setBundleFormat] = useState(""); - const [hostTargets, setHostTargets] = useState(""); - const [files, setFiles] = useState<File[]>([]); - const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]); - const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]); - const [codePluginFieldIssues, setCodePluginFieldIssues] = useState<string[]>([]); - const [codePluginCompatibility, setCodePluginCompatibility] = - useState<PackageCompatibility | null>(null); - const [status, setStatus] = useState<string | null>(null); - const [error, setError] = useState<string | null>(null); - - const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]); - const normalizedPaths = useMemo( - () => normalizePackageUploadFiles(files).map((entry) => entry.path), - [files], - ); - const normalizedPathSet = useMemo( - () => new Set(normalizedPaths.map((path) => path.toLowerCase())), - [normalizedPaths], - ); - const oversizedFiles = useMemo( - () => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES), - [files], - ); - const oversizedFileNames = useMemo( - () => oversizedFiles.slice(0, 3).map((file) => file.name), - [oversizedFiles], - ); - const validationError = - oversizedFiles.length > 0 - ? `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}` - : totalBytes > MAX_PUBLISH_TOTAL_BYTES - ? "Total file size exceeds 50MB." - : null; - const isMetadataLocked = files.length === 0; - const isSubmitting = status !== null; - const metadataDisabled = isMetadataLocked || isSubmitting; - - const onPickFiles = async (selected: File[]) => { - const expanded = await expandFilesWithReport(selected, { - includeBinaryArchiveFiles: true, + beforeLoad: ({ search }) => { + throw redirect({ + to: "/plugins/publish", + search, }); - const filtered = await filterIgnoredPackageFiles(expanded.files); - const normalized = normalizePackageUploadFiles(filtered.files); - const nextIgnoredPaths = [ - ...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths]), - ]; - setFiles(filtered.files); - setIgnoredPaths(nextIgnoredPaths); - setError(null); - const prefill = await derivePluginPrefill(normalized); - setDetectedPrefillFields(listPrefilledFields(prefill)); - setCodePluginFieldIssues(prefill.missingRequiredFields ?? []); - setCodePluginCompatibility(prefill.compatibility ?? null); - if (prefill.family === "code-plugin") setFamily(prefill.family); - if (prefill.name) setName(prefill.name); - if (prefill.displayName) setDisplayName(prefill.displayName); - if (prefill.version) setVersion(prefill.version); - if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo); - if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat); - if (prefill.hostTargets) setHostTargets(prefill.hostTargets); - }; - - useEffect(() => { - if (ownerHandle) return; - const personal = - publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0]; - if (personal?.publisher.handle) { - setOwnerHandle(personal.publisher.handle); - } - }, [ownerHandle, publishers]); - - return ( - <main className="py-10"> - <Container> - <header className="mb-6"> - <h1 className="mb-2 font-display text-2xl font-bold text-[color:var(--ink)]"> - {search.name ? "Publish Plugin Release" : "Publish Plugin"} - </h1> - <p className="text-sm text-[color:var(--ink-soft)]"> - Publish a native code plugin release. - </p> - <p className="text-sm text-[color:var(--ink-soft)]"> - New releases stay private until automated security checks and verification finish. - </p> - {search.name ? ( - <p className="text-sm text-[color:var(--ink-soft)]"> - Prefilled for {search.displayName ?? search.name} - {search.nextVersion && semver.valid(search.nextVersion) - ? ` \u00b7 suggested ${search.nextVersion}` - : ""} - </p> - ) : null} - </header> - - {SHOW_CLAWPACK_ONBOARDING_BANNER ? ( - <Card className="mb-5 border-[rgba(255,107,74,0.3)] bg-[rgba(255,107,74,0.06)]"> - <p className="text-sm font-medium text-[color:var(--ink)]"> - ClawPack publishing is moving to npm-pack .tgz uploads. - </p> - <p className="mt-1 text-sm text-[color:var(--ink-soft)]"> - Use the CLI for exact ClawPack bytes while the web uploader remains on the legacy - compatibility path. - </p> - </Card> - ) : null} - - <PackageSourceChooser - files={files} - totalBytes={totalBytes} - normalizedPaths={normalizedPaths} - normalizedPathSet={normalizedPathSet} - ignoredPaths={ignoredPaths} - detectedPrefillFields={detectedPrefillFields} - family={family} - validationError={validationError} - codePluginFieldIssues={codePluginFieldIssues} - codePluginCompatibility={codePluginCompatibility} - onPickFiles={onPickFiles} - /> - - <Card - className={isMetadataLocked ? "pointer-events-none opacity-60" : ""} - aria-disabled={isMetadataLocked} - > - <div className="flex flex-col gap-3"> - {!isAuthenticated ? ( - <div className="text-sm text-[color:var(--ink-soft)]">Log in to publish plugins.</div> - ) : null} - <p className="text-sm text-[color:var(--ink-soft)]"> - {isMetadataLocked - ? "Upload plugin code to detect the package shape and unlock the release form." - : "Metadata detected and prefilled. Review it, then fill any missing release details."} - </p> - <select - className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-sm text-[color:var(--ink)] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]" - value={family} - disabled={metadataDisabled} - onChange={(event) => setFamily(event.target.value as never)} - > - <option value="code-plugin">Code plugin</option> - </select> - <Input - placeholder="Plugin name" - value={name} - disabled={metadataDisabled} - onChange={(event) => setName(event.target.value)} - /> - <Input - placeholder="Display name" - value={displayName} - disabled={metadataDisabled} - onChange={(event) => setDisplayName(event.target.value)} - /> - <select - className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-sm text-[color:var(--ink)] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]" - value={ownerHandle} - disabled={metadataDisabled} - onChange={(event) => setOwnerHandle(event.target.value)} - > - {(publishers ?? []).map((entry) => ( - <option key={entry.publisher._id} value={entry.publisher.handle}> - @{entry.publisher.handle} · {entry.publisher.displayName} - </option> - ))} - </select> - <Input - placeholder="Version" - value={version} - disabled={metadataDisabled} - onChange={(event) => setVersion(event.target.value)} - /> - <Textarea - placeholder="Changelog" - rows={4} - value={changelog} - disabled={metadataDisabled} - onChange={(event) => setChangelog(event.target.value)} - /> - <Input - placeholder="Source repo (owner/repo)" - value={sourceRepo} - disabled={metadataDisabled} - onChange={(event) => setSourceRepo(event.target.value)} - /> - <Input - placeholder="Source commit" - value={sourceCommit} - disabled={metadataDisabled} - onChange={(event) => setSourceCommit(event.target.value)} - /> - <Input - placeholder="Source ref (tag or branch)" - value={sourceRef} - disabled={metadataDisabled} - onChange={(event) => setSourceRef(event.target.value)} - /> - <Input - placeholder="Source path" - value={sourcePath} - disabled={metadataDisabled} - onChange={(event) => setSourcePath(event.target.value)} - /> - {family === "bundle-plugin" ? ( - <> - <Input - placeholder="Bundle format" - value={bundleFormat} - disabled={metadataDisabled} - onChange={(event) => setBundleFormat(event.target.value)} - /> - <Input - placeholder="Host targets (comma separated)" - value={hostTargets} - disabled={metadataDisabled} - onChange={(event) => setHostTargets(event.target.value)} - /> - </> - ) : null} - <Button - variant="primary" - disabled={ - !isAuthenticated || - isMetadataLocked || - !name.trim() || - !version.trim() || - files.length === 0 || - Boolean(validationError) || - isSubmitting || - (family === "code-plugin" && - (!sourceRepo.trim() || !sourceCommit.trim() || codePluginFieldIssues.length > 0)) - } - onClick={() => { - startTransition(() => { - void (async () => { - try { - if (validationError) { - toast.error(validationError); - return; - } - if (family === "code-plugin" && codePluginFieldIssues.length > 0) { - toast.error( - `Missing required OpenClaw package metadata: ${codePluginFieldIssues.join(", ")}`, - ); - return; - } - setStatus("Uploading files..."); - setError(null); - const uploaded = await buildPackageUploadEntries(files, { - generateUploadUrl, - hashFile, - uploadFile, - }); - setStatus("Publishing release..."); - await publishRelease({ - payload: { - name: name.trim(), - displayName: displayName.trim() || undefined, - ownerHandle: ownerHandle || undefined, - family, - version: version.trim(), - changelog: changelog.trim(), - ...(sourceRepo.trim() && sourceCommit.trim() - ? { - source: { - kind: "github" as const, - repo: sourceRepo.trim(), - url: sourceRepo.trim().startsWith("http") - ? sourceRepo.trim() - : `https://github.com/${sourceRepo.trim().replace(/^\/+|\/+$/g, "")}`, - ref: sourceRef.trim() || sourceCommit.trim(), - commit: sourceCommit.trim(), - path: sourcePath.trim() || ".", - importedAt: Date.now(), - }, - } - : {}), - ...(family === "bundle-plugin" - ? { - bundle: { - format: bundleFormat.trim() || undefined, - hostTargets: hostTargets - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - }, - } - : {}), - files: uploaded, - }, - }); - setStatus( - "Published. Pending security checks and verification before public listing.", - ); - } catch (publishError) { - toast.error(formatPublishError(publishError)); - setStatus(null); - } - })(); - }); - }} - > - {status ?? "Publish"} - </Button> - {error ? <Badge variant="accent">{error}</Badge> : null} - </div> - </Card> - </Container> - </main> - ); -} + }, +}); diff --git a/src/routes/publish-skill.tsx b/src/routes/publish-skill.tsx index 508de5cf..e498fc8a 100644 --- a/src/routes/publish-skill.tsx +++ b/src/routes/publish-skill.tsx @@ -1,738 +1,13 @@ -import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; -import { - PLATFORM_SKILL_LICENSE, - PLATFORM_SKILL_LICENSE_NAME, - PLATFORM_SKILL_LICENSE_SUMMARY, -} from "clawhub-schema/licenseConstants"; -import { normalizeTextContentType } from "clawhub-schema/textFiles"; -import { useAction, useMutation, useQuery } from "convex/react"; -import { Upload as UploadIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import semver from "semver"; -import { toast } from "sonner"; -import { api } from "../../convex/_generated/api"; -import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits"; -import { EmptyState } from "../components/EmptyState"; -import { Container } from "../components/layout/Container"; -import { SignInButton } from "../components/SignInButton"; -import { Badge } from "../components/ui/badge"; -import { Button } from "../components/ui/button"; -import { Card, CardContent, CardTitle } from "../components/ui/card"; -import { Input } from "../components/ui/input"; -import { Label } from "../components/ui/label"; -import { Textarea } from "../components/ui/textarea"; -import { getSiteMode } from "../lib/site"; -import { getPublicSlugCollision } from "../lib/slugCollision"; -import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles"; -import { useAuthStatus } from "../lib/useAuthStatus"; -import { - formatBytes, - formatPublishError, - hashFile, - isTextFile, - readText, - uploadFile, -} from "./upload/-utils"; - -const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/publish-skill")({ validateSearch: (search) => ({ updateSlug: typeof search.updateSlug === "string" ? search.updateSlug : undefined, }), - component: Upload, -}); - -export function Upload() { - const { isAuthenticated, me } = useAuthStatus(); - const { updateSlug } = useSearch({ from: "/publish-skill" }); - const siteMode = getSiteMode(); - const isSoulMode = siteMode === "souls"; - const requiredFileLabel = isSoulMode ? "SOUL.md" : "SKILL.md"; - const contentLabel = isSoulMode ? "soul" : "skill"; - - const generateUploadUrl = useMutation(api.uploads.generateUploadUrl); - const publishVersion = useAction( - isSoulMode ? api.souls.publishVersion : api.skills.publishVersion, - ); - const generateChangelogPreview = useAction( - isSoulMode ? api.souls.generateChangelogPreview : api.skills.generateChangelogPreview, - ); - const existingSkill = useQuery( - api.skills.getBySlug, - !isSoulMode && updateSlug ? { slug: updateSlug } : "skip", - ); - const existingSoul = useQuery( - api.souls.getBySlug, - isSoulMode && updateSlug ? { slug: updateSlug } : "skip", - ); - const existing = (isSoulMode ? existingSoul : existingSkill) as - | { - skill?: { slug: string; displayName: string }; - soul?: { slug: string; displayName: string }; - latestVersion?: { version: string }; - } - | null - | undefined; - - const [hasAttempted, setHasAttempted] = useState(false); - const [files, setFiles] = useState<File[]>([]); - const [ignoredMacJunkPaths, setIgnoredMacJunkPaths] = useState<string[]>([]); - const [slug, setSlug] = useState(updateSlug ?? ""); - const [displayName, setDisplayName] = useState(""); - const [version, setVersion] = useState("1.0.0"); - const [tags, setTags] = useState("latest"); - const [acceptedLicenseTerms, setAcceptedLicenseTerms] = useState(false); - const [changelog, setChangelog] = useState(""); - const [changelogStatus, setChangelogStatus] = useState<"idle" | "loading" | "ready" | "error">( - "idle", - ); - const [changelogSource, setChangelogSource] = useState<"auto" | "user" | null>(null); - const changelogTouchedRef = useRef(false); - const changelogRequestRef = useRef(0); - const changelogKeyRef = useRef<string | null>(null); - const [status, setStatus] = useState<string | null>(null); - const isSubmitting = status !== null; - const [error, setError] = useState<string | null>(null); - const publisherMemberships = useQuery(api.publishers.listMine) as - | Array<{ - publisher: { - _id: string; - handle: string; - displayName: string; - kind: "user" | "org"; - }; - role: "owner" | "admin" | "publisher"; - }> - | undefined; - const [ownerHandle, setOwnerHandle] = useState(""); - const [isDragging, setIsDragging] = useState(false); - const fileInputRef = useRef<HTMLInputElement | null>(null); - const setFileInputRef = (node: HTMLInputElement | null) => { - fileInputRef.current = node; - if (node) { - node.setAttribute("webkitdirectory", ""); - node.setAttribute("directory", ""); - } - }; - const validationRef = useRef<HTMLDivElement | null>(null); - const navigate = useNavigate(); - const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]); - const stripRoot = useMemo(() => { - if (files.length === 0) return null; - const paths = files.map((file) => (file.webkitRelativePath || file.name).replace(/^\.\//, "")); - if (!paths.every((path) => path.includes("/"))) return null; - const firstSegment = paths[0]?.split("/")[0]; - if (!firstSegment) return null; - if (!paths.every((path) => path.startsWith(`${firstSegment}/`))) return null; - return firstSegment; - }, [files]); - const normalizedPaths = useMemo( - () => - files.map((file) => { - const raw = (file.webkitRelativePath || file.name).replace(/^\.\//, ""); - if (stripRoot && raw.startsWith(`${stripRoot}/`)) { - return raw.slice(stripRoot.length + 1); - } - return raw; - }), - [files, stripRoot], - ); - const hasRequiredFile = useMemo( - () => - normalizedPaths.some((path) => { - const lower = path.trim().toLowerCase(); - return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md"; - }), - [isSoulMode, normalizedPaths], - ); - const sizeLabel = totalBytes ? formatBytes(totalBytes) : "0 B"; - const oversizedFiles = useMemo( - () => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES), - [files], - ); - const oversizedFileNames = useMemo( - () => oversizedFiles.slice(0, 3).map((file) => file.name), - [oversizedFiles], - ); - const ignoredMacJunkNote = useMemo(() => { - if (ignoredMacJunkPaths.length === 0) return null; - const labels = Array.from( - new Set(ignoredMacJunkPaths.map((path) => path.split("/").at(-1) ?? path)), - ).slice(0, 3); - const suffix = ignoredMacJunkPaths.length > 3 ? ", ..." : ""; - const count = ignoredMacJunkPaths.length; - return `Ignored ${count} macOS junk file${count === 1 ? "" : "s"} (${labels.join(", ")}${suffix})`; - }, [ignoredMacJunkPaths]); - const trimmedSlug = slug.trim(); - const trimmedName = displayName.trim(); - const trimmedChangelog = changelog.trim(); - const trimmedVersion = version.trim(); - const slugAvailability = useQuery( - api.skills.checkSlugAvailability, - !isSoulMode && isAuthenticated && trimmedSlug && SLUG_PATTERN.test(trimmedSlug) - ? { slug: trimmedSlug.toLowerCase() } - : "skip", - ) as - | { - available: boolean; - reason: "available" | "taken" | "reserved"; - message: string | null; - url: string | null; - } - | null - | undefined; - const slugCollision = useMemo( - () => - getPublicSlugCollision({ - isSoulMode, - slug: trimmedSlug, - result: slugAvailability, - }), - [isSoulMode, slugAvailability, trimmedSlug], - ); - - useEffect(() => { - if (!existing?.latestVersion || (!existing?.skill && !existing?.soul)) return; - const name = existing.skill?.displayName ?? existing.soul?.displayName; - const nextSlug = existing.skill?.slug ?? existing.soul?.slug; - if (nextSlug) setSlug(nextSlug); - if (name) setDisplayName(name); - const nextVersion = semver.inc(existing.latestVersion.version, "patch"); - if (nextVersion) setVersion(nextVersion); - }, [existing]); - - useEffect(() => { - if (ownerHandle) return; - const personalPublisher = publisherMemberships?.find( - (entry) => entry.publisher.kind === "user", - ); - if (personalPublisher?.publisher.handle) { - setOwnerHandle(personalPublisher.publisher.handle); - } - }, [ownerHandle, publisherMemberships]); - - useEffect(() => { - if (changelogTouchedRef.current) return; - if (trimmedChangelog) return; - if (!trimmedSlug || !SLUG_PATTERN.test(trimmedSlug)) return; - if (!semver.valid(trimmedVersion)) return; - if (!hasRequiredFile) return; - if (files.length === 0) return; - - const requiredIndex = normalizedPaths.findIndex((path) => { - const lower = path.trim().toLowerCase(); - return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md"; + beforeLoad: ({ search }) => { + throw redirect({ + to: "/skills/publish", + search, }); - if (requiredIndex < 0) return; - - const requiredFile = files[requiredIndex]; - if (!requiredFile) return; - - const key = `${trimmedSlug}:${trimmedVersion}:${requiredFile.size}:${requiredFile.lastModified}:${normalizedPaths.length}`; - if (changelogKeyRef.current === key) return; - changelogKeyRef.current = key; - - const requestId = ++changelogRequestRef.current; - setChangelogStatus("loading"); - - void readText(requiredFile) - .then((text) => { - if (changelogRequestRef.current !== requestId) return null; - return generateChangelogPreview({ - slug: trimmedSlug, - version: trimmedVersion, - readmeText: text.slice(0, 20_000), - filePaths: normalizedPaths, - }); - }) - .then((result) => { - if (!result) return; - if (changelogRequestRef.current !== requestId) return; - setChangelog(result.changelog); - setChangelogSource("auto"); - setChangelogStatus("ready"); - }) - .catch(() => { - if (changelogRequestRef.current !== requestId) return; - setChangelogStatus("error"); - }); - }, [ - files, - generateChangelogPreview, - hasRequiredFile, - isSoulMode, - normalizedPaths, - trimmedChangelog, - trimmedSlug, - trimmedVersion, - ]); - const parsedTags = useMemo( - () => - tags - .split(",") - .map((tag) => tag.trim()) - .filter(Boolean), - [tags], - ); - const validation = useMemo(() => { - const issues: string[] = []; - if (!trimmedSlug) { - issues.push("Slug is required."); - } else if (!SLUG_PATTERN.test(trimmedSlug)) { - issues.push("Slug must be lowercase and use dashes only."); - } - if (!trimmedName) { - issues.push("Display name is required."); - } - if (!semver.valid(trimmedVersion)) { - issues.push("Version must be valid semver (e.g. 1.0.0)."); - } - if (parsedTags.length === 0) { - issues.push("At least one tag is required."); - } - if (!isSoulMode && !acceptedLicenseTerms) { - issues.push("Accept the MIT-0 license terms to publish this skill."); - } - if (files.length === 0) { - issues.push("Add at least one file."); - } - if (!hasRequiredFile) { - issues.push(`${requiredFileLabel} is required.`); - } - const invalidFiles = files.filter((file) => !isTextFile(file)); - if (invalidFiles.length > 0) { - issues.push( - `Remove non-text files: ${invalidFiles - .slice(0, 3) - .map((file) => file.name) - .join(", ")}`, - ); - } - if (oversizedFiles.length > 0) { - issues.push(`Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`); - } - if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) { - issues.push("Total file size exceeds 50MB."); - } - if (slugCollision) { - issues.push(slugCollision.message); - } - return { - issues, - ready: issues.length === 0, - }; - }, [ - trimmedSlug, - trimmedName, - trimmedVersion, - parsedTags.length, - acceptedLicenseTerms, - files, - hasRequiredFile, - isSoulMode, - totalBytes, - oversizedFiles.length, - oversizedFileNames, - requiredFileLabel, - slugCollision, - ]); - - // webkitdirectory/directory attributes are set via the ref callback (setFileInputRef) - // to ensure they persist across hydration and re-renders (#58) - - if (!isAuthenticated) { - return ( - <main className="py-10"> - <Container size="narrow"> - <EmptyState - title={`Sign in to publish a ${contentLabel}`} - description="You need to be signed in to publish skills on ClawHub." - > - <SignInButton /> - </EmptyState> - </Container> - </main> - ); - } - - async function applyExpandedFiles(selected: File[]) { - const report = await expandFilesWithReport(selected); - setFiles(report.files); - setIgnoredMacJunkPaths(report.ignoredMacJunkPaths); - } - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setHasAttempted(true); - if (!validation.ready) { - if (typeof validationRef.current?.scrollIntoView === "function") { - validationRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); - } - return; - } - if (slugCollision) { - setError(slugCollision.message); - toast.error(slugCollision.message); - return; - } - if (!isSoulMode && !acceptedLicenseTerms) { - const msg = "Accept the MIT-0 license terms to publish this skill."; - setError(msg); - toast.error(msg); - return; - } - setError(null); - if (oversizedFiles.length > 0) { - const msg = `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`; - setError(msg); - toast.error(msg); - return; - } - if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) { - const msg = "Total size exceeds 50MB per version."; - setError(msg); - toast.error(msg); - return; - } - if (!hasRequiredFile) { - const msg = `${requiredFileLabel} is required.`; - setError(msg); - toast.error(msg); - return; - } - setStatus("Uploading files…"); - - const uploaded = [] as Array<{ - path: string; - size: number; - storageId: string; - sha256: string; - contentType?: string; - }>; - - for (const file of files) { - const uploadUrl = await generateUploadUrl(); - const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, ""); - const path = - stripRoot && rawPath.startsWith(`${stripRoot}/`) - ? rawPath.slice(stripRoot.length + 1) - : rawPath; - const sha256 = await hashFile(file); - const storageId = await uploadFile(uploadUrl, file); - uploaded.push({ - path, - size: file.size, - storageId, - sha256, - contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined, - }); - } - - setStatus("Publishing…"); - try { - const result = await publishVersion({ - ownerHandle: isSoulMode ? undefined : ownerHandle || undefined, - slug: trimmedSlug, - displayName: trimmedName, - version: trimmedVersion, - changelog: trimmedChangelog, - acceptLicenseTerms: isSoulMode ? undefined : acceptedLicenseTerms, - tags: parsedTags, - files: uploaded, - }); - setStatus(null); - setError(null); - setHasAttempted(false); - setChangelogSource("user"); - if (result) { - toast.success(`Published ${trimmedSlug}@${trimmedVersion}`); - const ownerParam = ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown"); - void navigate({ - to: isSoulMode ? "/souls/$slug" : "/$owner/$slug", - params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug }, - }); - } - } catch (publishError) { - setStatus(null); - const message = formatPublishError(publishError); - setError(message); - toast.error(message); - } - } - - return ( - <main className="py-10"> - <Container size="narrow"> - <header className="flex flex-col gap-2 mb-6"> - <div> - <h1 className="font-display text-2xl font-bold text-[color:var(--ink)]"> - Publish a {contentLabel} - </h1> - <p className="text-sm text-[color:var(--ink-soft)]"> - Drop a folder with {requiredFileLabel} and text files. We will handle the rest. - </p> - </div> - </header> - - <form onSubmit={handleSubmit} className="flex flex-col gap-6"> - {/* Metadata panel */} - <Card> - <CardContent> - <Label htmlFor="slug">Slug</Label> - <div className="flex items-center gap-2"> - <Input - id="slug" - value={slug} - onChange={(event) => setSlug(event.target.value)} - placeholder={`${contentLabel}-name`} - /> - {trimmedSlug && SLUG_PATTERN.test(trimmedSlug) && slugAvailability ? ( - <Badge variant={slugAvailability.available ? "success" : "destructive"}> - {slugAvailability.available ? "Available" : "Taken"} - </Badge> - ) : null} - </div> - - <Label htmlFor="displayName">Display name</Label> - <Input - id="displayName" - value={displayName} - onChange={(event) => setDisplayName(event.target.value)} - placeholder={`My ${contentLabel}`} - /> - - {!isSoulMode ? ( - <> - <Label htmlFor="ownerHandle">Owner</Label> - <select - className="w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]" - id="ownerHandle" - value={ownerHandle} - onChange={(event) => setOwnerHandle(event.target.value)} - > - {(publisherMemberships ?? []).map((entry) => ( - <option key={entry.publisher._id} value={entry.publisher.handle}> - @{entry.publisher.handle} · {entry.publisher.displayName} - </option> - ))} - </select> - </> - ) : null} - - <Label htmlFor="version">Version</Label> - <Input - id="version" - value={version} - onChange={(event) => setVersion(event.target.value)} - placeholder="1.0.0" - /> - - <Label htmlFor="tags">Tags</Label> - <Input - id="tags" - value={tags} - onChange={(event) => setTags(event.target.value)} - placeholder="latest, stable" - /> - </CardContent> - </Card> - - {/* File upload panel */} - <Card> - <CardContent> - <label - className={`flex flex-col items-center gap-3 rounded-[var(--radius-md)] border-2 border-dashed p-8 transition-colors cursor-pointer ${ - isDragging - ? "border-[color:var(--accent)] bg-[color:var(--accent)]/5" - : "border-[color:var(--line)] bg-[color:var(--surface-muted)]" - }`} - onDragOver={(event) => { - event.preventDefault(); - setIsDragging(true); - }} - onDragLeave={() => setIsDragging(false)} - onDrop={(event) => { - event.preventDefault(); - setIsDragging(false); - const items = event.dataTransfer.items; - void (async () => { - const dropped = items?.length - ? await expandDroppedItems(items) - : Array.from(event.dataTransfer.files); - await applyExpandedFiles(dropped); - })(); - }} - > - <input - ref={setFileInputRef} - className="sr-only" - id="upload-files" - data-testid="upload-input" - type="file" - multiple - onChange={(event) => { - const picked = Array.from(event.target.files ?? []); - void applyExpandedFiles(picked); - }} - /> - <div className="flex flex-col items-center gap-2 text-center"> - <div className="flex items-center gap-3"> - <UploadIcon className="h-5 w-5 text-[color:var(--ink-soft)]" /> - <strong>Drop a folder</strong> - <span className="text-xs font-medium text-[color:var(--ink-soft)]"> - {files.length} files · {sizeLabel} - </span> - </div> - <span className="text-xs text-[color:var(--ink-soft)]"> - We keep folder paths and flatten the outer wrapper automatically. - </span> - <Button - variant="outline" - size="sm" - type="button" - onClick={() => fileInputRef.current?.click()} - > - Choose folder - </Button> - </div> - </label> - - <div className="flex flex-col gap-1 max-h-[300px] overflow-y-auto"> - {files.length === 0 ? ( - <div className="text-sm text-[color:var(--ink-soft)]">No files selected.</div> - ) : ( - normalizedPaths.map((path) => ( - <div - key={path} - className="flex items-center gap-2 rounded-[var(--radius-sm)] px-3 py-1.5 text-sm font-mono text-[color:var(--ink-soft)] bg-[color:var(--surface-muted)]" - > - <span>{path}</span> - </div> - )) - )} - </div> - {ignoredMacJunkNote ? ( - <div className="text-sm text-[color:var(--ink-soft)]">{ignoredMacJunkNote}</div> - ) : null} - </CardContent> - </Card> - - {/* Validation panel */} - <Card ref={validationRef}> - <CardContent> - <CardTitle>Validation</CardTitle> - {validation.issues.length === 0 ? ( - <div className="text-sm text-[color:var(--ink-soft)]">All checks passed.</div> - ) : ( - <ul className="flex flex-col gap-1 list-disc pl-5 text-sm text-[color:var(--ink-soft)]"> - {validation.issues.map((issue) => ( - <li key={issue}>{issue}</li> - ))} - </ul> - )} - {slugCollision?.url ? ( - <div className="text-sm text-[color:var(--ink-soft)]"> - Existing skill:{" "} - <a - href={slugCollision.url} - className="text-[color:var(--accent)] hover:underline" - > - {slugCollision.url} - </a> - </div> - ) : null} - </CardContent> - </Card> - - {/* License & changelog panel */} - <Card> - <CardContent> - {!isSoulMode ? ( - <> - <CardTitle>License</CardTitle> - <div className="flex flex-col gap-3"> - <Badge variant="accent"> - {PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_NAME} - </Badge> - <p className="text-sm text-[color:var(--ink-soft)]"> - All skills published on ClawHub are licensed under {PLATFORM_SKILL_LICENSE}.{" "} - {PLATFORM_SKILL_LICENSE_SUMMARY} - </p> - <p className="text-sm text-[color:var(--ink-soft)]"> - ClawHub does not support paid skills, per-skill pricing, or paywalled - releases. - </p> - <label className="flex items-start gap-2 text-sm cursor-pointer"> - <input - type="checkbox" - className="mt-0.5" - checked={acceptedLicenseTerms} - onChange={(event) => setAcceptedLicenseTerms(event.target.checked)} - /> - <span> - I have the rights to this skill and agree to publish it under{" "} - {PLATFORM_SKILL_LICENSE}. - </span> - </label> - </div> - </> - ) : null} - <Label htmlFor="changelog">Changelog</Label> - <Textarea - id="changelog" - rows={6} - value={changelog} - onChange={(event) => { - changelogTouchedRef.current = true; - setChangelogSource("user"); - setChangelog(event.target.value); - }} - placeholder={`Describe what changed in this ${contentLabel}...`} - /> - {changelogStatus === "loading" ? ( - <div className="text-sm text-[color:var(--ink-soft)]">Generating changelog…</div> - ) : null} - {changelogStatus === "error" ? ( - <div className="text-sm text-[color:var(--ink-soft)]"> - Could not auto-generate changelog. - </div> - ) : null} - {changelogSource === "auto" && changelog ? ( - <div className="text-sm text-[color:var(--ink-soft)]"> - Auto-generated changelog (edit as needed). - </div> - ) : null} - </CardContent> - </Card> - - {/* Submit row */} - <div className="flex items-center justify-between gap-4"> - <div className="flex flex-col gap-2"> - {error ? ( - <div className="text-sm font-medium text-red-600 dark:text-red-400" role="alert"> - {error} - </div> - ) : null} - {status ? <div className="text-sm text-[color:var(--ink-soft)]">{status}</div> : null} - {hasAttempted && !validation.ready ? ( - <div className="text-sm text-[color:var(--ink-soft)]"> - Fix validation issues to continue. - </div> - ) : null} - </div> - <Button - variant="primary" - size="lg" - type="submit" - disabled={!validation.ready || isSubmitting} - loading={isSubmitting} - > - Publish {contentLabel} - </Button> - </div> - </form> - </Container> - </main> - ); -} + }, +}); diff --git a/src/routes/skills/publish.tsx b/src/routes/skills/publish.tsx new file mode 100644 index 00000000..af1eef26 --- /dev/null +++ b/src/routes/skills/publish.tsx @@ -0,0 +1,738 @@ +import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router"; +import { + PLATFORM_SKILL_LICENSE, + PLATFORM_SKILL_LICENSE_NAME, + PLATFORM_SKILL_LICENSE_SUMMARY, +} from "clawhub-schema/licenseConstants"; +import { normalizeTextContentType } from "clawhub-schema/textFiles"; +import { useAction, useMutation, useQuery } from "convex/react"; +import { Upload as UploadIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import semver from "semver"; +import { toast } from "sonner"; +import { api } from "../../../convex/_generated/api"; +import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../../convex/lib/publishLimits"; +import { EmptyState } from "../../components/EmptyState"; +import { Container } from "../../components/layout/Container"; +import { SignInButton } from "../../components/SignInButton"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { Card, CardContent, CardTitle } from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { Textarea } from "../../components/ui/textarea"; +import { getSiteMode } from "../../lib/site"; +import { getPublicSlugCollision } from "../../lib/slugCollision"; +import { expandDroppedItems, expandFilesWithReport } from "../../lib/uploadFiles"; +import { useAuthStatus } from "../../lib/useAuthStatus"; +import { + formatBytes, + formatPublishError, + hashFile, + isTextFile, + readText, + uploadFile, +} from "../upload/-utils"; + +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export const Route = createFileRoute("/skills/publish")({ + validateSearch: (search) => ({ + updateSlug: typeof search.updateSlug === "string" ? search.updateSlug : undefined, + }), + component: Upload, +}); + +export function Upload() { + const { isAuthenticated, me } = useAuthStatus(); + const { updateSlug } = useSearch({ from: "/skills/publish" }); + const siteMode = getSiteMode(); + const isSoulMode = siteMode === "souls"; + const requiredFileLabel = isSoulMode ? "SOUL.md" : "SKILL.md"; + const contentLabel = isSoulMode ? "soul" : "skill"; + + const generateUploadUrl = useMutation(api.uploads.generateUploadUrl); + const publishVersion = useAction( + isSoulMode ? api.souls.publishVersion : api.skills.publishVersion, + ); + const generateChangelogPreview = useAction( + isSoulMode ? api.souls.generateChangelogPreview : api.skills.generateChangelogPreview, + ); + const existingSkill = useQuery( + api.skills.getBySlug, + !isSoulMode && updateSlug ? { slug: updateSlug } : "skip", + ); + const existingSoul = useQuery( + api.souls.getBySlug, + isSoulMode && updateSlug ? { slug: updateSlug } : "skip", + ); + const existing = (isSoulMode ? existingSoul : existingSkill) as + | { + skill?: { slug: string; displayName: string }; + soul?: { slug: string; displayName: string }; + latestVersion?: { version: string }; + } + | null + | undefined; + + const [hasAttempted, setHasAttempted] = useState(false); + const [files, setFiles] = useState<File[]>([]); + const [ignoredMacJunkPaths, setIgnoredMacJunkPaths] = useState<string[]>([]); + const [slug, setSlug] = useState(updateSlug ?? ""); + const [displayName, setDisplayName] = useState(""); + const [version, setVersion] = useState("1.0.0"); + const [tags, setTags] = useState("latest"); + const [acceptedLicenseTerms, setAcceptedLicenseTerms] = useState(false); + const [changelog, setChangelog] = useState(""); + const [changelogStatus, setChangelogStatus] = useState<"idle" | "loading" | "ready" | "error">( + "idle", + ); + const [changelogSource, setChangelogSource] = useState<"auto" | "user" | null>(null); + const changelogTouchedRef = useRef(false); + const changelogRequestRef = useRef(0); + const changelogKeyRef = useRef<string | null>(null); + const [status, setStatus] = useState<string | null>(null); + const isSubmitting = status !== null; + const [error, setError] = useState<string | null>(null); + const publisherMemberships = useQuery(api.publishers.listMine) as + | Array<{ + publisher: { + _id: string; + handle: string; + displayName: string; + kind: "user" | "org"; + }; + role: "owner" | "admin" | "publisher"; + }> + | undefined; + const [ownerHandle, setOwnerHandle] = useState(""); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef<HTMLInputElement | null>(null); + const setFileInputRef = (node: HTMLInputElement | null) => { + fileInputRef.current = node; + if (node) { + node.setAttribute("webkitdirectory", ""); + node.setAttribute("directory", ""); + } + }; + const validationRef = useRef<HTMLDivElement | null>(null); + const navigate = useNavigate(); + const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]); + const stripRoot = useMemo(() => { + if (files.length === 0) return null; + const paths = files.map((file) => (file.webkitRelativePath || file.name).replace(/^\.\//, "")); + if (!paths.every((path) => path.includes("/"))) return null; + const firstSegment = paths[0]?.split("/")[0]; + if (!firstSegment) return null; + if (!paths.every((path) => path.startsWith(`${firstSegment}/`))) return null; + return firstSegment; + }, [files]); + const normalizedPaths = useMemo( + () => + files.map((file) => { + const raw = (file.webkitRelativePath || file.name).replace(/^\.\//, ""); + if (stripRoot && raw.startsWith(`${stripRoot}/`)) { + return raw.slice(stripRoot.length + 1); + } + return raw; + }), + [files, stripRoot], + ); + const hasRequiredFile = useMemo( + () => + normalizedPaths.some((path) => { + const lower = path.trim().toLowerCase(); + return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md"; + }), + [isSoulMode, normalizedPaths], + ); + const sizeLabel = totalBytes ? formatBytes(totalBytes) : "0 B"; + const oversizedFiles = useMemo( + () => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES), + [files], + ); + const oversizedFileNames = useMemo( + () => oversizedFiles.slice(0, 3).map((file) => file.name), + [oversizedFiles], + ); + const ignoredMacJunkNote = useMemo(() => { + if (ignoredMacJunkPaths.length === 0) return null; + const labels = Array.from( + new Set(ignoredMacJunkPaths.map((path) => path.split("/").at(-1) ?? path)), + ).slice(0, 3); + const suffix = ignoredMacJunkPaths.length > 3 ? ", ..." : ""; + const count = ignoredMacJunkPaths.length; + return `Ignored ${count} macOS junk file${count === 1 ? "" : "s"} (${labels.join(", ")}${suffix})`; + }, [ignoredMacJunkPaths]); + const trimmedSlug = slug.trim(); + const trimmedName = displayName.trim(); + const trimmedChangelog = changelog.trim(); + const trimmedVersion = version.trim(); + const slugAvailability = useQuery( + api.skills.checkSlugAvailability, + !isSoulMode && isAuthenticated && trimmedSlug && SLUG_PATTERN.test(trimmedSlug) + ? { slug: trimmedSlug.toLowerCase() } + : "skip", + ) as + | { + available: boolean; + reason: "available" | "taken" | "reserved"; + message: string | null; + url: string | null; + } + | null + | undefined; + const slugCollision = useMemo( + () => + getPublicSlugCollision({ + isSoulMode, + slug: trimmedSlug, + result: slugAvailability, + }), + [isSoulMode, slugAvailability, trimmedSlug], + ); + + useEffect(() => { + if (!existing?.latestVersion || (!existing?.skill && !existing?.soul)) return; + const name = existing.skill?.displayName ?? existing.soul?.displayName; + const nextSlug = existing.skill?.slug ?? existing.soul?.slug; + if (nextSlug) setSlug(nextSlug); + if (name) setDisplayName(name); + const nextVersion = semver.inc(existing.latestVersion.version, "patch"); + if (nextVersion) setVersion(nextVersion); + }, [existing]); + + useEffect(() => { + if (ownerHandle) return; + const personalPublisher = publisherMemberships?.find( + (entry) => entry.publisher.kind === "user", + ); + if (personalPublisher?.publisher.handle) { + setOwnerHandle(personalPublisher.publisher.handle); + } + }, [ownerHandle, publisherMemberships]); + + useEffect(() => { + if (changelogTouchedRef.current) return; + if (trimmedChangelog) return; + if (!trimmedSlug || !SLUG_PATTERN.test(trimmedSlug)) return; + if (!semver.valid(trimmedVersion)) return; + if (!hasRequiredFile) return; + if (files.length === 0) return; + + const requiredIndex = normalizedPaths.findIndex((path) => { + const lower = path.trim().toLowerCase(); + return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md"; + }); + if (requiredIndex < 0) return; + + const requiredFile = files[requiredIndex]; + if (!requiredFile) return; + + const key = `${trimmedSlug}:${trimmedVersion}:${requiredFile.size}:${requiredFile.lastModified}:${normalizedPaths.length}`; + if (changelogKeyRef.current === key) return; + changelogKeyRef.current = key; + + const requestId = ++changelogRequestRef.current; + setChangelogStatus("loading"); + + void readText(requiredFile) + .then((text) => { + if (changelogRequestRef.current !== requestId) return null; + return generateChangelogPreview({ + slug: trimmedSlug, + version: trimmedVersion, + readmeText: text.slice(0, 20_000), + filePaths: normalizedPaths, + }); + }) + .then((result) => { + if (!result) return; + if (changelogRequestRef.current !== requestId) return; + setChangelog(result.changelog); + setChangelogSource("auto"); + setChangelogStatus("ready"); + }) + .catch(() => { + if (changelogRequestRef.current !== requestId) return; + setChangelogStatus("error"); + }); + }, [ + files, + generateChangelogPreview, + hasRequiredFile, + isSoulMode, + normalizedPaths, + trimmedChangelog, + trimmedSlug, + trimmedVersion, + ]); + const parsedTags = useMemo( + () => + tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + [tags], + ); + const validation = useMemo(() => { + const issues: string[] = []; + if (!trimmedSlug) { + issues.push("Slug is required."); + } else if (!SLUG_PATTERN.test(trimmedSlug)) { + issues.push("Slug must be lowercase and use dashes only."); + } + if (!trimmedName) { + issues.push("Display name is required."); + } + if (!semver.valid(trimmedVersion)) { + issues.push("Version must be valid semver (e.g. 1.0.0)."); + } + if (parsedTags.length === 0) { + issues.push("At least one tag is required."); + } + if (!isSoulMode && !acceptedLicenseTerms) { + issues.push("Accept the MIT-0 license terms to publish this skill."); + } + if (files.length === 0) { + issues.push("Add at least one file."); + } + if (!hasRequiredFile) { + issues.push(`${requiredFileLabel} is required.`); + } + const invalidFiles = files.filter((file) => !isTextFile(file)); + if (invalidFiles.length > 0) { + issues.push( + `Remove non-text files: ${invalidFiles + .slice(0, 3) + .map((file) => file.name) + .join(", ")}`, + ); + } + if (oversizedFiles.length > 0) { + issues.push(`Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`); + } + if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) { + issues.push("Total file size exceeds 50MB."); + } + if (slugCollision) { + issues.push(slugCollision.message); + } + return { + issues, + ready: issues.length === 0, + }; + }, [ + trimmedSlug, + trimmedName, + trimmedVersion, + parsedTags.length, + acceptedLicenseTerms, + files, + hasRequiredFile, + isSoulMode, + totalBytes, + oversizedFiles.length, + oversizedFileNames, + requiredFileLabel, + slugCollision, + ]); + + // webkitdirectory/directory attributes are set via the ref callback (setFileInputRef) + // to ensure they persist across hydration and re-renders (#58) + + if (!isAuthenticated) { + return ( + <main className="py-10"> + <Container size="narrow"> + <EmptyState + title={`Sign in to publish a ${contentLabel}`} + description="You need to be signed in to publish skills on ClawHub." + > + <SignInButton /> + </EmptyState> + </Container> + </main> + ); + } + + async function applyExpandedFiles(selected: File[]) { + const report = await expandFilesWithReport(selected); + setFiles(report.files); + setIgnoredMacJunkPaths(report.ignoredMacJunkPaths); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setHasAttempted(true); + if (!validation.ready) { + if (typeof validationRef.current?.scrollIntoView === "function") { + validationRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); + } + return; + } + if (slugCollision) { + setError(slugCollision.message); + toast.error(slugCollision.message); + return; + } + if (!isSoulMode && !acceptedLicenseTerms) { + const msg = "Accept the MIT-0 license terms to publish this skill."; + setError(msg); + toast.error(msg); + return; + } + setError(null); + if (oversizedFiles.length > 0) { + const msg = `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`; + setError(msg); + toast.error(msg); + return; + } + if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) { + const msg = "Total size exceeds 50MB per version."; + setError(msg); + toast.error(msg); + return; + } + if (!hasRequiredFile) { + const msg = `${requiredFileLabel} is required.`; + setError(msg); + toast.error(msg); + return; + } + setStatus("Uploading files…"); + + const uploaded = [] as Array<{ + path: string; + size: number; + storageId: string; + sha256: string; + contentType?: string; + }>; + + for (const file of files) { + const uploadUrl = await generateUploadUrl(); + const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, ""); + const path = + stripRoot && rawPath.startsWith(`${stripRoot}/`) + ? rawPath.slice(stripRoot.length + 1) + : rawPath; + const sha256 = await hashFile(file); + const storageId = await uploadFile(uploadUrl, file); + uploaded.push({ + path, + size: file.size, + storageId, + sha256, + contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined, + }); + } + + setStatus("Publishing…"); + try { + const result = await publishVersion({ + ownerHandle: isSoulMode ? undefined : ownerHandle || undefined, + slug: trimmedSlug, + displayName: trimmedName, + version: trimmedVersion, + changelog: trimmedChangelog, + acceptLicenseTerms: isSoulMode ? undefined : acceptedLicenseTerms, + tags: parsedTags, + files: uploaded, + }); + setStatus(null); + setError(null); + setHasAttempted(false); + setChangelogSource("user"); + if (result) { + toast.success(`Published ${trimmedSlug}@${trimmedVersion}`); + const ownerParam = ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown"); + void navigate({ + to: isSoulMode ? "/souls/$slug" : "/$owner/$slug", + params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug }, + }); + } + } catch (publishError) { + setStatus(null); + const message = formatPublishError(publishError); + setError(message); + toast.error(message); + } + } + + return ( + <main className="py-10"> + <Container size="narrow"> + <header className="flex flex-col gap-2 mb-6"> + <div> + <h1 className="font-display text-2xl font-bold text-[color:var(--ink)]"> + Publish a {contentLabel} + </h1> + <p className="text-sm text-[color:var(--ink-soft)]"> + Drop a folder with {requiredFileLabel} and text files. We will handle the rest. + </p> + </div> + </header> + + <form onSubmit={handleSubmit} className="flex flex-col gap-6"> + {/* Metadata panel */} + <Card> + <CardContent> + <Label htmlFor="slug">Slug</Label> + <div className="flex items-center gap-2"> + <Input + id="slug" + value={slug} + onChange={(event) => setSlug(event.target.value)} + placeholder={`${contentLabel}-name`} + /> + {trimmedSlug && SLUG_PATTERN.test(trimmedSlug) && slugAvailability ? ( + <Badge variant={slugAvailability.available ? "success" : "destructive"}> + {slugAvailability.available ? "Available" : "Taken"} + </Badge> + ) : null} + </div> + + <Label htmlFor="displayName">Display name</Label> + <Input + id="displayName" + value={displayName} + onChange={(event) => setDisplayName(event.target.value)} + placeholder={`My ${contentLabel}`} + /> + + {!isSoulMode ? ( + <> + <Label htmlFor="ownerHandle">Owner</Label> + <select + className="w-full min-h-[44px] rounded-[var(--radius-sm)] border px-3.5 py-[13px] text-[color:var(--ink)] transition-all duration-[180ms] ease-out border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] focus:outline-none focus:border-[color-mix(in_srgb,var(--accent)_70%,white)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent)_22%,transparent)] dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]" + id="ownerHandle" + value={ownerHandle} + onChange={(event) => setOwnerHandle(event.target.value)} + > + {(publisherMemberships ?? []).map((entry) => ( + <option key={entry.publisher._id} value={entry.publisher.handle}> + @{entry.publisher.handle} · {entry.publisher.displayName} + </option> + ))} + </select> + </> + ) : null} + + <Label htmlFor="version">Version</Label> + <Input + id="version" + value={version} + onChange={(event) => setVersion(event.target.value)} + placeholder="1.0.0" + /> + + <Label htmlFor="tags">Tags</Label> + <Input + id="tags" + value={tags} + onChange={(event) => setTags(event.target.value)} + placeholder="latest, stable" + /> + </CardContent> + </Card> + + {/* File upload panel */} + <Card> + <CardContent> + <label + className={`flex flex-col items-center gap-3 rounded-[var(--radius-md)] border-2 border-dashed p-8 transition-colors cursor-pointer ${ + isDragging + ? "border-[color:var(--accent)] bg-[color:var(--accent)]/5" + : "border-[color:var(--line)] bg-[color:var(--surface-muted)]" + }`} + onDragOver={(event) => { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + const items = event.dataTransfer.items; + void (async () => { + const dropped = items?.length + ? await expandDroppedItems(items) + : Array.from(event.dataTransfer.files); + await applyExpandedFiles(dropped); + })(); + }} + > + <input + ref={setFileInputRef} + className="sr-only" + id="upload-files" + data-testid="upload-input" + type="file" + multiple + onChange={(event) => { + const picked = Array.from(event.target.files ?? []); + void applyExpandedFiles(picked); + }} + /> + <div className="flex flex-col items-center gap-2 text-center"> + <div className="flex items-center gap-3"> + <UploadIcon className="h-5 w-5 text-[color:var(--ink-soft)]" /> + <strong>Drop a folder</strong> + <span className="text-xs font-medium text-[color:var(--ink-soft)]"> + {files.length} files · {sizeLabel} + </span> + </div> + <span className="text-xs text-[color:var(--ink-soft)]"> + We keep folder paths and flatten the outer wrapper automatically. + </span> + <Button + variant="outline" + size="sm" + type="button" + onClick={() => fileInputRef.current?.click()} + > + Choose folder + </Button> + </div> + </label> + + <div className="flex flex-col gap-1 max-h-[300px] overflow-y-auto"> + {files.length === 0 ? ( + <div className="text-sm text-[color:var(--ink-soft)]">No files selected.</div> + ) : ( + normalizedPaths.map((path) => ( + <div + key={path} + className="flex items-center gap-2 rounded-[var(--radius-sm)] px-3 py-1.5 text-sm font-mono text-[color:var(--ink-soft)] bg-[color:var(--surface-muted)]" + > + <span>{path}</span> + </div> + )) + )} + </div> + {ignoredMacJunkNote ? ( + <div className="text-sm text-[color:var(--ink-soft)]">{ignoredMacJunkNote}</div> + ) : null} + </CardContent> + </Card> + + {/* Validation panel */} + <Card ref={validationRef}> + <CardContent> + <CardTitle>Validation</CardTitle> + {validation.issues.length === 0 ? ( + <div className="text-sm text-[color:var(--ink-soft)]">All checks passed.</div> + ) : ( + <ul className="flex flex-col gap-1 list-disc pl-5 text-sm text-[color:var(--ink-soft)]"> + {validation.issues.map((issue) => ( + <li key={issue}>{issue}</li> + ))} + </ul> + )} + {slugCollision?.url ? ( + <div className="text-sm text-[color:var(--ink-soft)]"> + Existing skill:{" "} + <a + href={slugCollision.url} + className="text-[color:var(--accent)] hover:underline" + > + {slugCollision.url} + </a> + </div> + ) : null} + </CardContent> + </Card> + + {/* License & changelog panel */} + <Card> + <CardContent> + {!isSoulMode ? ( + <> + <CardTitle>License</CardTitle> + <div className="flex flex-col gap-3"> + <Badge variant="accent"> + {PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_NAME} + </Badge> + <p className="text-sm text-[color:var(--ink-soft)]"> + All skills published on ClawHub are licensed under {PLATFORM_SKILL_LICENSE}.{" "} + {PLATFORM_SKILL_LICENSE_SUMMARY} + </p> + <p className="text-sm text-[color:var(--ink-soft)]"> + ClawHub does not support paid skills, per-skill pricing, or paywalled + releases. + </p> + <label className="flex items-start gap-2 text-sm cursor-pointer"> + <input + type="checkbox" + className="mt-0.5" + checked={acceptedLicenseTerms} + onChange={(event) => setAcceptedLicenseTerms(event.target.checked)} + /> + <span> + I have the rights to this skill and agree to publish it under{" "} + {PLATFORM_SKILL_LICENSE}. + </span> + </label> + </div> + </> + ) : null} + <Label htmlFor="changelog">Changelog</Label> + <Textarea + id="changelog" + rows={6} + value={changelog} + onChange={(event) => { + changelogTouchedRef.current = true; + setChangelogSource("user"); + setChangelog(event.target.value); + }} + placeholder={`Describe what changed in this ${contentLabel}...`} + /> + {changelogStatus === "loading" ? ( + <div className="text-sm text-[color:var(--ink-soft)]">Generating changelog…</div> + ) : null} + {changelogStatus === "error" ? ( + <div className="text-sm text-[color:var(--ink-soft)]"> + Could not auto-generate changelog. + </div> + ) : null} + {changelogSource === "auto" && changelog ? ( + <div className="text-sm text-[color:var(--ink-soft)]"> + Auto-generated changelog (edit as needed). + </div> + ) : null} + </CardContent> + </Card> + + {/* Submit row */} + <div className="flex items-center justify-between gap-4"> + <div className="flex flex-col gap-2"> + {error ? ( + <div className="text-sm font-medium text-red-600 dark:text-red-400" role="alert"> + {error} + </div> + ) : null} + {status ? <div className="text-sm text-[color:var(--ink-soft)]">{status}</div> : null} + {hasAttempted && !validation.ready ? ( + <div className="text-sm text-[color:var(--ink-soft)]"> + Fix validation issues to continue. + </div> + ) : null} + </div> + <Button + variant="primary" + size="lg" + type="submit" + disabled={!validation.ready || isSubmitting} + loading={isSubmitting} + > + Publish {contentLabel} + </Button> + </div> + </form> + </Container> + </main> + ); +} diff --git a/src/routes/upload.tsx b/src/routes/upload.tsx index f04917b6..c4870b6b 100644 --- a/src/routes/upload.tsx +++ b/src/routes/upload.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute("/upload")({ }), beforeLoad: ({ search }) => { throw redirect({ - to: "/publish-skill", + to: "/skills/publish", search, }); },