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
This commit is contained in:
Patrick Erichsen
2026-05-07 19:03:40 -07:00
committed by GitHub
parent 86898837fb
commit 8c86d6f570
49 changed files with 1943 additions and 1160 deletions
+2
View File
@@ -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
@@ -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
+6
View File
@@ -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",
+7
View File
@@ -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;
}
+18
View File
@@ -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.`;
}
+210 -5
View File
@@ -888,24 +888,64 @@ function makeInsertReleaseCtx(
existing: Record<string, unknown> | null,
priorReleases: Array<Record<string, unknown>> = [],
recordsById: Record<string, Record<string, unknown>> = {},
runtimePackages: Array<Record<string, unknown>> = [],
) {
const patch = vi.fn();
const insert = vi.fn().mockResolvedValueOnce("packageReleases:new");
let insertedPackage: Record<string, unknown> | null = null;
const insert = vi.fn(async (table: string, doc: Record<string, unknown>) => {
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<string, unknown>();
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({
+46 -10
View File
@@ -1,5 +1,6 @@
import {
PackagePublishRequestSchema,
getPackageScopeOwnerMismatch,
parseArk,
validateOpenClawExternalCodePluginPackageContents,
type PackageArtifactSummary,
@@ -2104,7 +2105,11 @@ export const insertAuditLogInternal = internalMutation({
},
});
async function softDeletePackageDoc(ctx: Pick<MutationCtx, "db">, pkg: Doc<"packages">) {
async function softDeletePackageDoc(
ctx: Pick<MutationCtx, "db">,
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<MutationCtx, "db">, pkg: Doc<"pack
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
.collect();
let releaseCount = 0;
const deletedReleaseIds: Array<Id<"packageReleases">> = [];
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<MutationCtx, "db">, 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<Doc<"users"> | 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 : "<unknown>";
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 : "<unknown>";
const nextRuntimeIdLabel = typeof args.runtimeId === "string" ? args.runtimeId : "<unknown>";
const nextVersionLabel = typeof args.version === "string" ? args.version : "<unknown>";
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`,
);
+21
View File
@@ -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 = {
+7
View File
@@ -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;
}
+24
View File
@@ -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) => {
+2
View File
@@ -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);
+7 -6
View File
@@ -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:
+2
View File
@@ -154,6 +154,7 @@ Stores your API token + cached registry URL.
- Requires semver: `--version 1.2.3`.
- `--owner <handle>` 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 <handle>` 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`.
+85
View File
@@ -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/<owner>/<slug>
```
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.
+1 -1
View File
@@ -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);
});
+1 -1
View File
@@ -113,7 +113,7 @@ describe("prod http smoke", () => {
expect(html).toContain("<title>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!");
});
+1 -1
View File
@@ -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);
});
+12
View File
@@ -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;
};
};
+16
View File
@@ -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
+1
View File
@@ -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"}
+1
View File
@@ -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";
+1
View File
@@ -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";
+1 -1
View File
@@ -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"}
{"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"}
+8
View File
@@ -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", {}>;
+21
View File
@@ -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"');
File diff suppressed because one or more lines are too long
+19
View File
@@ -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;
+1
View File
@@ -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";
+23
View File
@@ -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];
+26
View File
@@ -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"),
@@ -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();
@@ -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,
});
});
});
@@ -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,
+2 -2
View File
@@ -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" })
+2 -5
View File
@@ -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"
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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,
+42
View File
@@ -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,
+2 -5
View File
@@ -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>
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -11,7 +11,7 @@ export const Route = createFileRoute("/plugins/new")({
}),
beforeLoad: ({ search }) => {
throw redirect({
to: "/publish-plugin",
to: "/plugins/publish",
search,
});
},
+422
View File
@@ -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} &middot; {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>
);
}
+7 -373
View File
@@ -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} &middot; {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>
);
}
},
});
+7 -732
View File
@@ -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>
);
}
},
});
+738
View File
@@ -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>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const Route = createFileRoute("/upload")({
}),
beforeLoad: ({ search }) => {
throw redirect({
to: "/publish-skill",
to: "/skills/publish",
search,
});
},