mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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!");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Vendored
+12
@@ -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;
|
||||
};
|
||||
};
|
||||
Vendored
+16
@@ -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
@@ -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"}
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+8
@@ -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", {}>;
|
||||
|
||||
Vendored
+21
@@ -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"');
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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,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,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];
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+78
-5
@@ -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,
|
||||
@@ -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" })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,7 @@ export const Route = createFileRoute("/plugins/new")({
|
||||
}),
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: "/publish-plugin",
|
||||
to: "/plugins/publish",
|
||||
search,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
DocsLinks,
|
||||
getPackageScopeOwnerMismatch,
|
||||
type PackageCompatibility,
|
||||
} from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../../convex/lib/publishLimits";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { PackageSourceChooser } from "../../components/PackageSourceChooser";
|
||||
import { SignInButton } from "../../components/SignInButton";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
buildPackageUploadEntries,
|
||||
filterIgnoredPackageFiles,
|
||||
normalizePackageUploadFiles,
|
||||
} from "../../lib/packageUpload";
|
||||
import { derivePluginPrefill, listPrefilledFields } from "../../lib/pluginPublishPrefill";
|
||||
import { expandFilesWithReport } from "../../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
import { formatPublishError, hashFile, uploadFile } from "../upload/-utils";
|
||||
|
||||
export const Route = createFileRoute("/plugins/publish")({
|
||||
validateSearch: (search) => ({
|
||||
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
|
||||
name: typeof search.name === "string" ? search.name : undefined,
|
||||
displayName: typeof search.displayName === "string" ? search.displayName : undefined,
|
||||
family: search.family === "code-plugin" ? search.family : undefined,
|
||||
nextVersion: typeof search.nextVersion === "string" ? search.nextVersion : undefined,
|
||||
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
|
||||
}),
|
||||
component: PublishPluginRoute,
|
||||
});
|
||||
|
||||
const apiRefs = api as unknown as {
|
||||
packages: {
|
||||
publishRelease: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const SHOW_CLAWPACK_ONBOARDING_BANNER = false;
|
||||
export function PublishPluginRoute() {
|
||||
const search = useSearch({ from: "/plugins/publish" });
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const publishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (args: {
|
||||
payload: unknown;
|
||||
}) => Promise<unknown>;
|
||||
const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">("code-plugin");
|
||||
const [name, setName] = useState(search.name ?? "");
|
||||
const [displayName, setDisplayName] = useState(search.displayName ?? "");
|
||||
const [ownerHandle, setOwnerHandle] = useState(search.ownerHandle ?? "");
|
||||
const [version, setVersion] = useState(search.nextVersion ?? "0.1.0");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [sourceRepo, setSourceRepo] = useState(search.sourceRepo ?? "");
|
||||
const [sourceCommit, setSourceCommit] = useState("");
|
||||
const [sourceRef, setSourceRef] = useState("");
|
||||
const [sourcePath, setSourcePath] = useState(".");
|
||||
const [bundleFormat, setBundleFormat] = useState("");
|
||||
const [hostTargets, setHostTargets] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
|
||||
const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]);
|
||||
const [codePluginFieldIssues, setCodePluginFieldIssues] = useState<string[]>([]);
|
||||
const [codePluginCompatibility, setCodePluginCompatibility] =
|
||||
useState<PackageCompatibility | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
() => normalizePackageUploadFiles(files).map((entry) => entry.path),
|
||||
[files],
|
||||
);
|
||||
const normalizedPathSet = useMemo(
|
||||
() => new Set(normalizedPaths.map((path) => path.toLowerCase())),
|
||||
[normalizedPaths],
|
||||
);
|
||||
const oversizedFiles = useMemo(
|
||||
() => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES),
|
||||
[files],
|
||||
);
|
||||
const oversizedFileNames = useMemo(
|
||||
() => oversizedFiles.slice(0, 3).map((file) => file.name),
|
||||
[oversizedFiles],
|
||||
);
|
||||
const validationError =
|
||||
oversizedFiles.length > 0
|
||||
? `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`
|
||||
: totalBytes > MAX_PUBLISH_TOTAL_BYTES
|
||||
? "Total file size exceeds 50MB."
|
||||
: null;
|
||||
const isMetadataLocked = files.length === 0;
|
||||
const isSubmitting = status !== null;
|
||||
const metadataDisabled = isMetadataLocked || isSubmitting;
|
||||
const ownerScopeError = useMemo(() => {
|
||||
return getPackageScopeOwnerMismatch(name, ownerHandle)?.message ?? null;
|
||||
}, [name, ownerHandle]);
|
||||
|
||||
const onPickFiles = async (selected: File[]) => {
|
||||
const expanded = await expandFilesWithReport(selected, {
|
||||
includeBinaryArchiveFiles: true,
|
||||
});
|
||||
const filtered = await filterIgnoredPackageFiles(expanded.files);
|
||||
const normalized = normalizePackageUploadFiles(filtered.files);
|
||||
const nextIgnoredPaths = [
|
||||
...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths]),
|
||||
];
|
||||
setFiles(filtered.files);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setError(null);
|
||||
const prefill = await derivePluginPrefill(normalized);
|
||||
setDetectedPrefillFields(listPrefilledFields(prefill));
|
||||
setCodePluginFieldIssues(prefill.missingRequiredFields ?? []);
|
||||
setCodePluginCompatibility(prefill.compatibility ?? null);
|
||||
if (prefill.family === "code-plugin") setFamily(prefill.family);
|
||||
if (prefill.name) setName(prefill.name);
|
||||
if (prefill.displayName) setDisplayName(prefill.displayName);
|
||||
if (prefill.version) setVersion(prefill.version);
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat);
|
||||
if (prefill.hostTargets) setHostTargets(prefill.hostTargets);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personal =
|
||||
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher.handle) {
|
||||
setOwnerHandle(personal.publisher.handle);
|
||||
}
|
||||
}, [ownerHandle, publishers]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container size="narrow">
|
||||
<EmptyState
|
||||
title="Sign in to publish a plugin"
|
||||
description="You need to be signed in to publish plugins on ClawHub."
|
||||
>
|
||||
<SignInButton />
|
||||
</EmptyState>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<header className="mb-6">
|
||||
<h1 className="mb-2 font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
{search.name ? "Publish Plugin Release" : "Publish Plugin"}
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Publish a native code plugin release.
|
||||
</p>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
New releases stay private until automated security checks and verification finish.
|
||||
</p>
|
||||
{search.name ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Prefilled for {search.displayName ?? search.name}
|
||||
{search.nextVersion && semver.valid(search.nextVersion)
|
||||
? ` \u00b7 suggested ${search.nextVersion}`
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{SHOW_CLAWPACK_ONBOARDING_BANNER ? (
|
||||
<Card className="mb-5 border-[rgba(255,107,74,0.3)] bg-[rgba(255,107,74,0.06)]">
|
||||
<p className="text-sm font-medium text-[color:var(--ink)]">
|
||||
ClawPack publishing is moving to npm-pack .tgz uploads.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Use the CLI for exact ClawPack bytes while the web uploader remains on the legacy
|
||||
compatibility path.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<PackageSourceChooser
|
||||
files={files}
|
||||
totalBytes={totalBytes}
|
||||
normalizedPaths={normalizedPaths}
|
||||
normalizedPathSet={normalizedPathSet}
|
||||
ignoredPaths={ignoredPaths}
|
||||
detectedPrefillFields={detectedPrefillFields}
|
||||
family={family}
|
||||
validationError={validationError}
|
||||
codePluginFieldIssues={codePluginFieldIssues}
|
||||
codePluginCompatibility={codePluginCompatibility}
|
||||
onPickFiles={onPickFiles}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={isMetadataLocked ? "pointer-events-none opacity-60" : ""}
|
||||
aria-disabled={isMetadataLocked}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{isMetadataLocked
|
||||
? "Upload plugin code to detect the package shape and unlock the release form."
|
||||
: "Metadata detected and prefilled. Review it, then fill any missing release details."}
|
||||
</p>
|
||||
<select
|
||||
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-sm text-[color:var(--ink)] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={family}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setFamily(event.target.value as never)}
|
||||
>
|
||||
<option value="code-plugin">Code plugin</option>
|
||||
</select>
|
||||
<Input
|
||||
placeholder="Plugin name"
|
||||
value={name}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
{ownerScopeError ? (
|
||||
<Badge variant="accent">
|
||||
<span>{ownerScopeError}</span>
|
||||
<a
|
||||
href={DocsLinks.clawhub.packageScopeFaq}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Learn how publishing works
|
||||
</a>
|
||||
</Badge>
|
||||
) : null}
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-sm text-[color:var(--ink)] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={ownerHandle}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setOwnerHandle(event.target.value)}
|
||||
>
|
||||
{(publishers ?? []).map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher.handle}>
|
||||
@{entry.publisher.handle} · {entry.publisher.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
placeholder="Version"
|
||||
value={version}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Changelog"
|
||||
rows={4}
|
||||
value={changelog}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Source repo (owner/repo)"
|
||||
value={sourceRepo}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceRepo(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Source commit"
|
||||
value={sourceCommit}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceCommit(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Source ref (tag or branch)"
|
||||
value={sourceRef}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceRef(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Source path"
|
||||
value={sourcePath}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
{family === "bundle-plugin" ? (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Bundle format"
|
||||
value={bundleFormat}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setBundleFormat(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Host targets (comma separated)"
|
||||
value={hostTargets}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setHostTargets(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={
|
||||
!isAuthenticated ||
|
||||
isMetadataLocked ||
|
||||
!name.trim() ||
|
||||
!version.trim() ||
|
||||
files.length === 0 ||
|
||||
Boolean(validationError) ||
|
||||
Boolean(ownerScopeError) ||
|
||||
isSubmitting ||
|
||||
(family === "code-plugin" &&
|
||||
(!sourceRepo.trim() || !sourceCommit.trim() || codePluginFieldIssues.length > 0))
|
||||
}
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
if (ownerScopeError) {
|
||||
toast.error(ownerScopeError);
|
||||
return;
|
||||
}
|
||||
if (family === "code-plugin" && codePluginFieldIssues.length > 0) {
|
||||
toast.error(
|
||||
`Missing required OpenClaw package metadata: ${codePluginFieldIssues.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading files...");
|
||||
setError(null);
|
||||
const uploaded = await buildPackageUploadEntries(files, {
|
||||
generateUploadUrl,
|
||||
hashFile,
|
||||
uploadFile,
|
||||
});
|
||||
setStatus("Publishing release...");
|
||||
await publishRelease({
|
||||
payload: {
|
||||
name: name.trim(),
|
||||
displayName: displayName.trim() || undefined,
|
||||
ownerHandle: ownerHandle || undefined,
|
||||
family,
|
||||
version: version.trim(),
|
||||
changelog: changelog.trim(),
|
||||
...(sourceRepo.trim() && sourceCommit.trim()
|
||||
? {
|
||||
source: {
|
||||
kind: "github" as const,
|
||||
repo: sourceRepo.trim(),
|
||||
url: sourceRepo.trim().startsWith("http")
|
||||
? sourceRepo.trim()
|
||||
: `https://github.com/${sourceRepo.trim().replace(/^\/+|\/+$/g, "")}`,
|
||||
ref: sourceRef.trim() || sourceCommit.trim(),
|
||||
commit: sourceCommit.trim(),
|
||||
path: sourcePath.trim() || ".",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: bundleFormat.trim() || undefined,
|
||||
hostTargets: hostTargets
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
files: uploaded,
|
||||
},
|
||||
});
|
||||
setStatus(
|
||||
"Published. Pending security checks and verification before public listing.",
|
||||
);
|
||||
} catch (publishError) {
|
||||
toast.error(formatPublishError(publishError));
|
||||
setStatus(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{status ?? "Publish"}
|
||||
</Button>
|
||||
{error ? <Badge variant="accent">{error}</Badge> : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,4 @@
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
import type { PackageCompatibility } from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { PackageSourceChooser } from "../components/PackageSourceChooser";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import {
|
||||
buildPackageUploadEntries,
|
||||
filterIgnoredPackageFiles,
|
||||
normalizePackageUploadFiles,
|
||||
} from "../lib/packageUpload";
|
||||
import { derivePluginPrefill, listPrefilledFields } from "../lib/pluginPublishPrefill";
|
||||
import { expandFilesWithReport } from "../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { formatPublishError, hashFile, uploadFile } from "./upload/-utils";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/publish-plugin")({
|
||||
validateSearch: (search) => ({
|
||||
@@ -32,353 +9,10 @@ export const Route = createFileRoute("/publish-plugin")({
|
||||
nextVersion: typeof search.nextVersion === "string" ? search.nextVersion : undefined,
|
||||
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
|
||||
}),
|
||||
component: PublishPluginRoute,
|
||||
});
|
||||
|
||||
const apiRefs = api as unknown as {
|
||||
packages: {
|
||||
publishRelease: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const SHOW_CLAWPACK_ONBOARDING_BANNER = false;
|
||||
|
||||
export function PublishPluginRoute() {
|
||||
const search = useSearch({ from: "/publish-plugin" });
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const publishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (args: {
|
||||
payload: unknown;
|
||||
}) => Promise<unknown>;
|
||||
const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">("code-plugin");
|
||||
const [name, setName] = useState(search.name ?? "");
|
||||
const [displayName, setDisplayName] = useState(search.displayName ?? "");
|
||||
const [ownerHandle, setOwnerHandle] = useState(search.ownerHandle ?? "");
|
||||
const [version, setVersion] = useState(search.nextVersion ?? "0.1.0");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [sourceRepo, setSourceRepo] = useState(search.sourceRepo ?? "");
|
||||
const [sourceCommit, setSourceCommit] = useState("");
|
||||
const [sourceRef, setSourceRef] = useState("");
|
||||
const [sourcePath, setSourcePath] = useState(".");
|
||||
const [bundleFormat, setBundleFormat] = useState("");
|
||||
const [hostTargets, setHostTargets] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
|
||||
const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]);
|
||||
const [codePluginFieldIssues, setCodePluginFieldIssues] = useState<string[]>([]);
|
||||
const [codePluginCompatibility, setCodePluginCompatibility] =
|
||||
useState<PackageCompatibility | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
() => normalizePackageUploadFiles(files).map((entry) => entry.path),
|
||||
[files],
|
||||
);
|
||||
const normalizedPathSet = useMemo(
|
||||
() => new Set(normalizedPaths.map((path) => path.toLowerCase())),
|
||||
[normalizedPaths],
|
||||
);
|
||||
const oversizedFiles = useMemo(
|
||||
() => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES),
|
||||
[files],
|
||||
);
|
||||
const oversizedFileNames = useMemo(
|
||||
() => oversizedFiles.slice(0, 3).map((file) => file.name),
|
||||
[oversizedFiles],
|
||||
);
|
||||
const validationError =
|
||||
oversizedFiles.length > 0
|
||||
? `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`
|
||||
: totalBytes > MAX_PUBLISH_TOTAL_BYTES
|
||||
? "Total file size exceeds 50MB."
|
||||
: null;
|
||||
const isMetadataLocked = files.length === 0;
|
||||
const isSubmitting = status !== null;
|
||||
const metadataDisabled = isMetadataLocked || isSubmitting;
|
||||
|
||||
const onPickFiles = async (selected: File[]) => {
|
||||
const expanded = await expandFilesWithReport(selected, {
|
||||
includeBinaryArchiveFiles: true,
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: "/plugins/publish",
|
||||
search,
|
||||
});
|
||||
const filtered = await filterIgnoredPackageFiles(expanded.files);
|
||||
const normalized = normalizePackageUploadFiles(filtered.files);
|
||||
const nextIgnoredPaths = [
|
||||
...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths]),
|
||||
];
|
||||
setFiles(filtered.files);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setError(null);
|
||||
const prefill = await derivePluginPrefill(normalized);
|
||||
setDetectedPrefillFields(listPrefilledFields(prefill));
|
||||
setCodePluginFieldIssues(prefill.missingRequiredFields ?? []);
|
||||
setCodePluginCompatibility(prefill.compatibility ?? null);
|
||||
if (prefill.family === "code-plugin") setFamily(prefill.family);
|
||||
if (prefill.name) setName(prefill.name);
|
||||
if (prefill.displayName) setDisplayName(prefill.displayName);
|
||||
if (prefill.version) setVersion(prefill.version);
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat);
|
||||
if (prefill.hostTargets) setHostTargets(prefill.hostTargets);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personal =
|
||||
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher.handle) {
|
||||
setOwnerHandle(personal.publisher.handle);
|
||||
}
|
||||
}, [ownerHandle, publishers]);
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<header className="mb-6">
|
||||
<h1 className="mb-2 font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
{search.name ? "Publish Plugin Release" : "Publish Plugin"}
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Publish a native code plugin release.
|
||||
</p>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
New releases stay private until automated security checks and verification finish.
|
||||
</p>
|
||||
{search.name ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Prefilled for {search.displayName ?? search.name}
|
||||
{search.nextVersion && semver.valid(search.nextVersion)
|
||||
? ` \u00b7 suggested ${search.nextVersion}`
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{SHOW_CLAWPACK_ONBOARDING_BANNER ? (
|
||||
<Card className="mb-5 border-[rgba(255,107,74,0.3)] bg-[rgba(255,107,74,0.06)]">
|
||||
<p className="text-sm font-medium text-[color:var(--ink)]">
|
||||
ClawPack publishing is moving to npm-pack .tgz uploads.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Use the CLI for exact ClawPack bytes while the web uploader remains on the legacy
|
||||
compatibility path.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<PackageSourceChooser
|
||||
files={files}
|
||||
totalBytes={totalBytes}
|
||||
normalizedPaths={normalizedPaths}
|
||||
normalizedPathSet={normalizedPathSet}
|
||||
ignoredPaths={ignoredPaths}
|
||||
detectedPrefillFields={detectedPrefillFields}
|
||||
family={family}
|
||||
validationError={validationError}
|
||||
codePluginFieldIssues={codePluginFieldIssues}
|
||||
codePluginCompatibility={codePluginCompatibility}
|
||||
onPickFiles={onPickFiles}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={isMetadataLocked ? "pointer-events-none opacity-60" : ""}
|
||||
aria-disabled={isMetadataLocked}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{!isAuthenticated ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">Log in to publish plugins.</div>
|
||||
) : null}
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{isMetadataLocked
|
||||
? "Upload plugin code to detect the package shape and unlock the release form."
|
||||
: "Metadata detected and prefilled. Review it, then fill any missing release details."}
|
||||
</p>
|
||||
<select
|
||||
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-sm text-[color:var(--ink)] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={family}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setFamily(event.target.value as never)}
|
||||
>
|
||||
<option value="code-plugin">Code plugin</option>
|
||||
</select>
|
||||
<Input
|
||||
placeholder="Plugin name"
|
||||
value={name}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="min-h-[44px] w-full rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3.5 py-[13px] text-sm text-[color:var(--ink)] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={ownerHandle}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setOwnerHandle(event.target.value)}
|
||||
>
|
||||
{(publishers ?? []).map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher.handle}>
|
||||
@{entry.publisher.handle} · {entry.publisher.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
placeholder="Version"
|
||||
value={version}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Changelog"
|
||||
rows={4}
|
||||
value={changelog}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Source repo (owner/repo)"
|
||||
value={sourceRepo}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceRepo(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Source commit"
|
||||
value={sourceCommit}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceCommit(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Source ref (tag or branch)"
|
||||
value={sourceRef}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceRef(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Source path"
|
||||
value={sourcePath}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
{family === "bundle-plugin" ? (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Bundle format"
|
||||
value={bundleFormat}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setBundleFormat(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Host targets (comma separated)"
|
||||
value={hostTargets}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setHostTargets(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={
|
||||
!isAuthenticated ||
|
||||
isMetadataLocked ||
|
||||
!name.trim() ||
|
||||
!version.trim() ||
|
||||
files.length === 0 ||
|
||||
Boolean(validationError) ||
|
||||
isSubmitting ||
|
||||
(family === "code-plugin" &&
|
||||
(!sourceRepo.trim() || !sourceCommit.trim() || codePluginFieldIssues.length > 0))
|
||||
}
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
if (family === "code-plugin" && codePluginFieldIssues.length > 0) {
|
||||
toast.error(
|
||||
`Missing required OpenClaw package metadata: ${codePluginFieldIssues.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading files...");
|
||||
setError(null);
|
||||
const uploaded = await buildPackageUploadEntries(files, {
|
||||
generateUploadUrl,
|
||||
hashFile,
|
||||
uploadFile,
|
||||
});
|
||||
setStatus("Publishing release...");
|
||||
await publishRelease({
|
||||
payload: {
|
||||
name: name.trim(),
|
||||
displayName: displayName.trim() || undefined,
|
||||
ownerHandle: ownerHandle || undefined,
|
||||
family,
|
||||
version: version.trim(),
|
||||
changelog: changelog.trim(),
|
||||
...(sourceRepo.trim() && sourceCommit.trim()
|
||||
? {
|
||||
source: {
|
||||
kind: "github" as const,
|
||||
repo: sourceRepo.trim(),
|
||||
url: sourceRepo.trim().startsWith("http")
|
||||
? sourceRepo.trim()
|
||||
: `https://github.com/${sourceRepo.trim().replace(/^\/+|\/+$/g, "")}`,
|
||||
ref: sourceRef.trim() || sourceCommit.trim(),
|
||||
commit: sourceCommit.trim(),
|
||||
path: sourcePath.trim() || ".",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: bundleFormat.trim() || undefined,
|
||||
hostTargets: hostTargets
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
files: uploaded,
|
||||
},
|
||||
});
|
||||
setStatus(
|
||||
"Published. Pending security checks and verification before public listing.",
|
||||
);
|
||||
} catch (publishError) {
|
||||
toast.error(formatPublishError(publishError));
|
||||
setStatus(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{status ?? "Publish"}
|
||||
</Button>
|
||||
{error ? <Badge variant="accent">{error}</Badge> : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const Route = createFileRoute("/upload")({
|
||||
}),
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: "/publish-skill",
|
||||
to: "/skills/publish",
|
||||
search,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user