feat(admin): support org profile updates (#3416)

This commit is contained in:
Patrick Erichsen
2026-08-05 12:32:55 -07:00
committed by GitHub
parent 5d6c9c6021
commit 6381d789ab
10 changed files with 621 additions and 3 deletions
+5 -1
View File
@@ -115,6 +115,7 @@ has asked for fuzzy handle resolution or the exact handle is ambiguous.
```text
official
create <handle>
profile update <handle>
remove-member <handle> <member>
delete <handle>
repair-scoped-packages <csv>
@@ -127,6 +128,8 @@ bun run admin -- org official list
bun run admin -- org official add <handle> --reason "<reason>" --yes
bun run admin -- org official remove <handle> --reason "<reason>" --yes
bun run admin -- org create <handle> --display-name "<name>" --member <user-handle> --role owner
bun run admin -- org profile update <handle> --bio "<description>" --reason "<reason>" --yes
bun run admin -- org profile update <handle> --logo-file <path> --reason "<reason>" --yes
bun run admin -- org remove-member <handle> <member-handle>
bun run admin -- org delete <handle> --reason "<reason>" # dry-run
bun run admin -- org delete <handle> --reason "<reason>" --apply
@@ -136,7 +139,8 @@ bun run admin -- org repair-scoped-packages <csv> --apply
`org create` requires `--member`; it must not add the moderator running the
command as an implicit owner. `org delete` only works for empty org publishers
and defaults to dry-run.
and defaults to dry-run. `org profile update` accepts a bio, a PNG/JPEG/WebP
logo under 2 MB, or both, and records the required reason in the audit log.
### Plugin Packages
+99
View File
@@ -1697,6 +1697,105 @@ describe("httpApiV1 handlers", () => {
);
});
it("users/publisher-profile updates an org bio and stores a logo for admin", async () => {
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return {
ok: true,
publisherId: "publishers:heygen",
handle: "heygen-com",
bio: "HeyGen is an AI video platform.",
image: "https://storage.example/heygen-logo",
bioUpdated: true,
logoUpdated: true,
};
});
const store = vi.fn(async () => "storage:heygen-logo");
const remove = vi.fn(async () => {});
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const form = new FormData();
form.set(
"payload",
JSON.stringify({
handle: "HeyGen-Com",
bio: "HeyGen is an AI video platform.",
reason: "Refresh official publisher profile",
}),
);
form.set(
"logo",
new File([new Uint8Array([137, 80, 78, 71])], "heygen.png", { type: "image/png" }),
);
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({
runQuery: vi.fn(),
runAction: vi.fn(),
runMutation,
storage: { store, delete: remove },
}),
new Request("https://example.com/api/v1/users/publisher-profile", {
method: "POST",
body: form,
}),
);
if (response.status !== 200) throw new Error(await response.text());
expect(store).toHaveBeenCalledOnce();
expect(remove).not.toHaveBeenCalled();
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
actorUserId: "users:admin",
handle: "heygen-com",
bio: "HeyGen is an AI video platform.",
imageStorageId: "storage:heygen-logo",
reason: "Refresh official publisher profile",
}),
);
expect(await response.json()).toMatchObject({
ok: true,
handle: "heygen-com",
bioUpdated: true,
logoUpdated: true,
});
});
it("users/publisher-profile forbids non-admin api tokens before storing files", async () => {
const store = vi.fn(async () => "storage:unused");
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
throw new Error(`unexpected mutation ${JSON.stringify(args)}`);
});
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:member",
user: { _id: "users:member", role: "user" },
} as never);
const form = new FormData();
form.set(
"payload",
JSON.stringify({
handle: "opik",
bio: "Opik is an AI observability platform.",
reason: "Refresh official publisher profile",
}),
);
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery: vi.fn(), runAction: vi.fn(), runMutation, storage: { store } }),
new Request("https://example.com/api/v1/users/publisher-profile", {
method: "POST",
body: form,
}),
);
expect(response.status).toBe(403);
expect(store).not.toHaveBeenCalled();
});
it("users/publisher-recovery plans personal publisher recovery for admin", async () => {
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
+89
View File
@@ -24,6 +24,7 @@ const usersV1InternalRefs = internal as unknown as {
removeOrgPublisherMemberInternal: unknown;
removeOfficialPublisherInternal: unknown;
recoverPersonalPublisherInternal: unknown;
updateOrgPublisherProfileInternal: unknown;
};
users: {
getBanAppealContextByGitHubProviderAccountIdInternal: unknown;
@@ -98,12 +99,21 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
action !== "publisher-delete" &&
action !== "publisher-official" &&
action !== "publisher-member" &&
action !== "publisher-profile" &&
action !== "publisher-reclaim" &&
action !== "publisher-recovery"
) {
return text("Not found", 404, rate.headers);
}
if (action === "publisher-profile") {
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!authResult.ok) return authResult.response;
const admin = requireAdminOrResponse(authResult.user, rate.headers);
if (!admin.ok) return admin.response;
return handleAdminUpdatePublisherProfile(ctx, request, authResult.userId, rate.headers);
}
const payloadResult = await parseJsonPayload(request, rate.headers);
if (!payloadResult.ok) return payloadResult.response;
const payload = payloadResult.payload;
@@ -930,6 +940,85 @@ async function handleAdminEnsurePublisher(
}
}
const PUBLISHER_PROFILE_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
const PUBLISHER_PROFILE_IMAGE_CONTENT_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
async function handleAdminUpdatePublisherProfile(
ctx: ActionCtx,
request: Request,
actorUserId: Id<"users">,
headers: HeadersInit,
) {
let form: FormData;
try {
form = await request.formData();
} catch {
return text("Invalid multipart form", 400, headers);
}
const payloadRaw = form.get("payload");
if (typeof payloadRaw !== "string") return text("Missing payload", 400, headers);
let payload: Record<string, unknown>;
try {
const parsed = JSON.parse(payloadRaw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return text("JSON payload must be an object", 400, headers);
}
payload = parsed as Record<string, unknown>;
} catch {
return text("Invalid JSON payload", 400, headers);
}
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
const reason = typeof payload.reason === "string" ? payload.reason.trim() : "";
const hasBio = Object.prototype.hasOwnProperty.call(payload, "bio");
const bio = typeof payload.bio === "string" ? payload.bio.trim() : undefined;
if (!handle) return text("Missing handle", 400, headers);
if (!reason) return text("Missing reason", 400, headers);
if (reason.length > 500) return text("Reason too long (max 500 chars)", 400, headers);
if (hasBio && typeof payload.bio !== "string") return text("bio must be a string", 400, headers);
const logoParts = form.getAll("logo");
if (logoParts.length > 1) return text("Upload one logo", 400, headers);
const logo = logoParts[0];
if (typeof logo === "string") return text("logo must be a file", 400, headers);
if (!hasBio && !logo) return text("bio or logo required", 400, headers);
if (
logo &&
(logo.size <= 0 ||
logo.size > PUBLISHER_PROFILE_IMAGE_MAX_BYTES ||
!PUBLISHER_PROFILE_IMAGE_CONTENT_TYPES.has(logo.type))
) {
return text("Logo must be a PNG, JPEG, or WebP image smaller than 2 MB", 400, headers);
}
let imageStorageId: Id<"_storage"> | undefined;
try {
if (logo) imageStorageId = await ctx.storage.store(logo);
const result = await runUsersV1MutationRef<{
ok: true;
publisherId: Id<"publishers">;
handle: string;
bio: string | null;
image: string | null;
bioUpdated: boolean;
logoUpdated: boolean;
}>(ctx, usersV1InternalRefs.publishers.updateOrgPublisherProfileInternal, {
actorUserId,
handle,
...(hasBio ? { bio: bio ?? "" } : {}),
...(imageStorageId ? { imageStorageId } : {}),
reason,
});
return json(result, 200, headers);
} catch (error) {
if (imageStorageId) await ctx.storage.delete(imageStorageId);
const message = error instanceof Error ? error.message : "Publisher profile update failed";
if (/not found/i.test(message)) return text(message, 404, headers);
if (/unauthorized|forbidden/i.test(message)) return text("Forbidden", 403, headers);
return text(message, 400, headers);
}
}
async function handleBanAppealUnban(
ctx: ActionCtx,
request: Request,
+147
View File
@@ -42,6 +42,7 @@ import {
resolvePublishTargetForUserInternal,
setTrustedPublisherInternal,
updateProfile,
updateOrgPublisherProfileInternal,
} from "./publishers";
vi.mock("@convex-dev/auth/server", () => ({
@@ -139,6 +140,27 @@ const ensureOrgPublisherHandleInternalHandler = (
>
)._handler;
const updateOrgPublisherProfileInternalHandler = (
updateOrgPublisherProfileInternal as unknown as WrappedHandler<
{
actorUserId: string;
handle: string;
bio?: string;
imageStorageId?: string;
reason: string;
},
{
ok: true;
publisherId: string;
handle: string;
bio: string | null;
image: string | null;
bioUpdated: boolean;
logoUpdated: boolean;
}
>
)._handler;
const removeOrgPublisherMemberInternalHandler = (
removeOrgPublisherMemberInternal as unknown as WrappedHandler<
{
@@ -9255,6 +9277,131 @@ describe("legacy publisher migration", () => {
]);
});
it("lets admins update an org bio while preserving its logo and recording the reason", async () => {
const publisher = {
_id: "publishers:opik",
kind: "org",
handle: "opik",
displayName: "Opik",
bio: undefined,
image: "https://storage.example/opik-logo",
imageStorageId: "storage:opik-logo",
};
const patch = vi.fn(async () => {});
const insert = vi.fn(async () => "auditLogs:1");
const deleteStorage = vi.fn(async () => {});
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:admin") return { _id: id, role: "admin" };
return null;
}),
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ unique: vi.fn(async () => publisher) })),
})),
patch,
insert,
delete: vi.fn(),
replace: vi.fn(),
normalizeId: vi.fn(),
},
storage: { delete: deleteStorage },
};
await expect(
updateOrgPublisherProfileInternalHandler(ctx as never, {
actorUserId: "users:admin",
handle: "OPIK",
bio: "Open-source AI observability and evaluation platform.",
reason: "Replace placeholder copy with verified official publisher description",
}),
).resolves.toMatchObject({
ok: true,
handle: "opik",
bioUpdated: true,
logoUpdated: false,
image: "https://storage.example/opik-logo",
});
expect(patch).toHaveBeenCalledWith(
"publishers:opik",
expect.objectContaining({
bio: "Open-source AI observability and evaluation platform.",
}),
);
expect(deleteStorage).not.toHaveBeenCalled();
expect(insert).toHaveBeenCalledWith(
"auditLogs",
expect.objectContaining({
actorUserId: "users:admin",
action: "publisher.profile.update",
targetId: "publishers:opik",
metadata: expect.objectContaining({
source: "publisher.org.admin",
reason: "Replace placeholder copy with verified official publisher description",
bioUpdated: true,
logoUpdated: false,
}),
}),
);
});
it("replaces a stored org logo only after validating the uploaded file", async () => {
const publisher = {
_id: "publishers:heygen",
kind: "org",
handle: "heygen-com",
displayName: "HeyGen",
image: "https://storage.example/old-logo",
imageStorageId: "storage:old-logo",
};
const patch = vi.fn(async () => {});
const deleteStorage = vi.fn(async () => {});
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:admin") return { _id: id, role: "admin" };
return null;
}),
system: {
get: vi.fn(async () => ({ contentType: "image/png", size: 1024 })),
},
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ unique: vi.fn(async () => publisher) })),
})),
patch,
insert: vi.fn(async () => "auditLogs:1"),
delete: vi.fn(),
replace: vi.fn(),
normalizeId: vi.fn(),
},
storage: {
getUrl: vi.fn(async () => "https://storage.example/new-logo"),
delete: deleteStorage,
},
};
await expect(
updateOrgPublisherProfileInternalHandler(ctx as never, {
actorUserId: "users:admin",
handle: "heygen-com",
imageStorageId: "storage:new-logo",
reason: "Replace personal avatar with the official HeyGen brand symbol",
}),
).resolves.toMatchObject({
bioUpdated: false,
logoUpdated: true,
image: "https://storage.example/new-logo",
});
expect(patch).toHaveBeenCalledWith(
"publishers:heygen",
expect.objectContaining({
image: "https://storage.example/new-logo",
imageStorageId: "storage:new-logo",
}),
);
expect(deleteStorage).toHaveBeenCalledWith("storage:old-logo");
});
it("lets an admin remove one org owner when another owner remains", async () => {
const publisherMembers = [
{
+89
View File
@@ -3295,6 +3295,95 @@ export const ensureOrgPublisherHandleInternal = internalMutation({
handler: async (ctx, args) => await ensureOrgPublisherHandleWithActor(ctx, args),
});
export const updateOrgPublisherProfileInternal = internalMutation({
args: {
actorUserId: v.id("users"),
handle: v.string(),
bio: v.optional(v.string()),
imageStorageId: v.optional(v.id("_storage")),
reason: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
assertAdmin(actor);
const handle = normalizePublisherHandle(args.handle);
if (!handle || !PUBLISHER_HANDLE_PATTERN.test(handle)) {
throw new ConvexError(PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE);
}
const reason = args.reason.trim();
if (!reason) throw new ConvexError("Reason required");
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
if (args.bio === undefined && args.imageStorageId === undefined) {
throw new ConvexError("Bio or logo required");
}
const publisher = await getPublisherByHandle(ctx, handle);
if (!publisher || publisher.kind !== "org" || publisher.deletedAt || publisher.deactivatedAt) {
throw new ConvexError("Publisher not found");
}
const bioUpdated = args.bio !== undefined;
const nextBio = bioUpdated ? args.bio?.trim() || undefined : publisher.bio;
let nextImage = publisher.image;
if (args.imageStorageId) {
const metadata = await ctx.db.system.get("_storage", args.imageStorageId);
if (
!metadata ||
metadata.size <= 0 ||
metadata.size > PUBLISHER_IMAGE_MAX_BYTES ||
!metadata.contentType ||
!PUBLISHER_IMAGE_CONTENT_TYPES.has(metadata.contentType)
) {
throw new ConvexError("Logo must be a PNG, JPEG, or WebP image smaller than 2 MB");
}
const uploadedImageUrl = await ctx.storage.getUrl(args.imageStorageId);
if (!uploadedImageUrl) throw new ConvexError("Uploaded logo is no longer available");
nextImage = uploadedImageUrl;
}
const now = Date.now();
await ctx.db.patch(publisher._id, {
...(bioUpdated ? { bio: nextBio } : {}),
...(args.imageStorageId ? { image: nextImage, imageStorageId: args.imageStorageId } : {}),
updatedAt: now,
});
if (
args.imageStorageId &&
publisher.imageStorageId &&
publisher.imageStorageId !== args.imageStorageId
) {
await ctx.storage.delete(publisher.imageStorageId);
}
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
action: "publisher.profile.update",
targetType: "publisher",
targetId: publisher._id,
metadata: {
source: "publisher.org.admin",
reason,
bioUpdated,
logoUpdated: Boolean(args.imageStorageId),
...(bioUpdated ? { bio: nextBio ?? null } : {}),
...(args.imageStorageId ? { imageStorageId: args.imageStorageId } : {}),
},
createdAt: now,
});
return {
ok: true as const,
publisherId: publisher._id,
handle,
bio: nextBio ?? null,
image: nextImage ?? null,
bioUpdated,
logoUpdated: Boolean(args.imageStorageId),
};
},
});
export const removeOrgPublisherMemberInternal = internalMutation({
args: {
actorUserId: v.id("users"),
+3
View File
@@ -94,10 +94,13 @@ Org publisher administration:
```bash
bun run admin -- org create <handle> --member <handle> [--display-name <name>] [--role owner|admin|publisher] [--trusted] [--json]
bun run admin -- org profile update <handle> [--bio <text>] [--logo-file <path>] --reason <text> [--yes] [--json]
```
`org create` requires `--member` and defaults that member to `owner`; it does
not add the admin running the command as an org member.
`org profile update` requires a bio, a PNG/JPEG/WebP logo under 2 MB, or both,
and records the supplied audit reason.
Publisher administration:
+16
View File
@@ -49,6 +49,7 @@ import {
cmdRemoveOfficialOrg,
cmdRemoveOrgMember,
cmdRepairScopedPackages,
cmdUpdateOrgProfile,
} from "./commands/orgs.js";
import {
cmdDeletePackageTrustedPublisher,
@@ -527,6 +528,21 @@ function registerOfficialPublisherCommands(command: Command) {
function registerOrgCommands(command: Command) {
registerOfficialPublisherCommands(command);
const profile = command.command("profile").description("Manage org publisher profiles");
profile
.command("update")
.description("Update an org publisher bio or logo")
.argument("<handle>", "Org publisher handle")
.option("--bio <text>", "Publisher bio")
.option("--logo-file <path>", "PNG, JPEG, or WebP logo under 2 MB")
.requiredOption("--reason <reason>", "Audit reason")
.option("--yes", "Skip confirmation")
.option("--json", "Output JSON")
.action(async (handle, options) => {
const opts = await resolveGlobalOpts();
await cmdUpdateOrgProfile(opts, handle, options, isInputAllowed());
});
command
.command("create")
.description("Create or update an org publisher")
@@ -31,6 +31,7 @@ const {
cmdRemoveOfficialOrg,
cmdRemoveOrgMember,
cmdRepairScopedPackages,
cmdUpdateOrgProfile,
} = await import("./orgs");
afterEach(() => {
@@ -169,6 +170,75 @@ describe("cmdCreateOrg", () => {
});
});
describe("cmdUpdateOrgProfile", () => {
it("updates an org bio and uploads a validated logo through the staff profile endpoint", async () => {
const dir = await mkdtemp(join(tmpdir(), "clawhub-admin-org-profile-"));
const logoFile = join(dir, "heygen-logo.png");
await writeFile(logoFile, new Uint8Array([137, 80, 78, 71]));
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
publisherId: "publishers:heygen",
handle: "heygen-com",
bio: "HeyGen is an AI video platform.",
image: "https://storage.example/heygen-logo",
bioUpdated: true,
logoUpdated: true,
});
try {
await cmdUpdateOrgProfile(
makeGlobalOpts(),
"@HeyGen-Com",
{
bio: " HeyGen is an AI video platform. ",
logoFile,
reason: "Refresh official publisher profile",
yes: true,
json: true,
},
false,
);
} finally {
await rm(dir, { force: true, recursive: true });
}
expect(httpMocks.apiRequestForm).toHaveBeenCalledWith(
"https://clawhub.ai",
expect.objectContaining({
method: "POST",
path: "/api/v1/users/publisher-profile",
token: "tkn",
retryCount: 0,
form: expect.any(FormData),
}),
expect.anything(),
);
const call = httpMocks.apiRequestForm.mock.calls[0]?.[1] as { form: FormData };
const payload = call.form.get("payload");
expect(typeof payload).toBe("string");
expect(JSON.parse(payload as string)).toEqual({
handle: "heygen-com",
bio: "HeyGen is an AI video platform.",
reason: "Refresh official publisher profile",
});
const logo = call.form.get("logo") as File;
expect(logo.name).toBe("heygen-logo.png");
expect(logo.type).toBe("image/png");
});
it("requires at least one profile field", async () => {
await expect(
cmdUpdateOrgProfile(
makeGlobalOpts(),
"opik",
{ reason: "Refresh official publisher profile", yes: true },
false,
),
).rejects.toThrow(/--bio or --logo-file required/i);
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
});
});
describe("cmdRemoveOrgMember", () => {
it("removes a user from an org publisher by handle", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
+91 -2
View File
@@ -1,4 +1,5 @@
import { readFile, writeFile } from "node:fs/promises";
import { readFile, stat, writeFile } from "node:fs/promises";
import { basename, extname } from "node:path";
import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js";
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
@@ -9,7 +10,7 @@ import {
isInteractive,
promptConfirm,
} from "../../../clawhub/src/cli/ui.js";
import { apiRequest } from "../../../clawhub/src/http.js";
import { apiRequest, apiRequestForm } from "../../../clawhub/src/http.js";
import type { ApiV1PackageRepairNameResponse } from "../../../clawhub/src/schema/index.js";
import {
ApiV1OfficialPublisherListResponseSchema,
@@ -18,6 +19,7 @@ import {
ApiRoutes,
ApiV1PublisherDeleteResponseSchema,
ApiV1PublisherEnsureResponseSchema,
ApiV1PublisherProfileUpdateResponseSchema,
ApiV1PublisherReclaimResponseSchema,
ApiV1PublisherRemoveMemberResponseSchema,
} from "../../../clawhub/src/schema/index.js";
@@ -36,6 +38,14 @@ type OrgRemoveMemberOptions = {
json?: boolean;
};
type OrgProfileUpdateOptions = {
bio?: string;
logoFile?: string;
reason?: string;
yes?: boolean;
json?: boolean;
};
type OrgDeleteOptions = {
apply?: boolean;
reason?: string;
@@ -107,6 +117,14 @@ function normalizeRoleOrFail(role: string | undefined): OrgMemberRole {
return fail("--role must be owner, admin, or publisher");
}
function publisherLogoContentType(path: string) {
const extension = extname(path).toLowerCase();
if (extension === ".png") return "image/png";
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
if (extension === ".webp") return "image/webp";
return fail("--logo-file must be a PNG, JPEG, or WebP image");
}
export async function cmdCreateOrg(opts: GlobalOpts, handle: string, options: OrgCreateOptions) {
const orgHandle = normalizeHandleOrFail(handle, "Org handle");
const displayName = options.displayName?.trim();
@@ -195,6 +213,63 @@ export async function cmdRemoveOrgMember(
}
}
export async function cmdUpdateOrgProfile(
opts: GlobalOpts,
handle: string,
options: OrgProfileUpdateOptions,
inputAllowed: boolean,
) {
const orgHandle = normalizeHandleOrFail(handle, "Org handle");
const bio = options.bio?.trim();
const logoFile = options.logoFile?.trim();
const reason = normalizeReasonOrFail(options.reason);
if (!bio && !logoFile) fail("--bio or --logo-file required");
await confirmProfileUpdate(orgHandle, options, inputAllowed);
const form = new FormData();
form.set(
"payload",
JSON.stringify({
handle: orgHandle,
...(bio ? { bio } : {}),
reason,
}),
);
if (logoFile) {
const metadata = await stat(logoFile);
if (!metadata.isFile()) fail("--logo-file must point to a file");
if (metadata.size <= 0 || metadata.size > 2 * 1024 * 1024) {
fail("--logo-file must be smaller than 2 MB");
}
const contentType = publisherLogoContentType(logoFile);
const bytes = await readFile(logoFile);
form.set("logo", new File([new Uint8Array(bytes)], basename(logoFile), { type: contentType }));
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = options.json ? null : createCrabLoader(`Updating @${orgHandle} profile`);
try {
const result = await apiRequestForm(
registry,
{
method: "POST",
path: `${ApiRoutes.users}/publisher-profile`,
token,
form,
retryCount: 0,
},
ApiV1PublisherProfileUpdateResponseSchema,
);
spinner?.succeed(`Updated @${result.handle} profile`);
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return result;
} catch (error) {
spinner?.fail(formatError(error));
throw error;
}
}
export async function cmdDeleteOrg(
opts: GlobalOpts,
handle: string,
@@ -572,6 +647,20 @@ async function confirmOfficialOrgUpdate(
if (!confirmed) fail("Canceled");
}
async function confirmProfileUpdate(
handle: string,
options: OrgProfileUpdateOptions,
inputAllowed: boolean,
) {
if (options.yes) return;
if (!isInteractive() || inputAllowed === false) fail("Pass --yes (no input)");
const fields = [options.bio?.trim() ? "bio" : "", options.logoFile?.trim() ? "logo" : ""]
.filter(Boolean)
.join(" and ");
const confirmed = await promptConfirm(`Update @${handle} ${fields}? (admin only)`);
if (!confirmed) fail("Canceled");
}
function parseScopedPackageRepairCsv(content: string): ScopedPackageRepairRow[] {
const records = parseCsvRecords(content).filter((record) =>
record.some((cell) => cell.trim().length > 0),
+12
View File
@@ -317,6 +317,18 @@ export const ApiV1PublisherRemoveMemberResponseSchema = type({
export type ApiV1PublisherRemoveMemberResponse =
(typeof ApiV1PublisherRemoveMemberResponseSchema)[inferred];
export const ApiV1PublisherProfileUpdateResponseSchema = type({
ok: "true",
publisherId: "string",
handle: "string",
bio: "string|null",
image: "string|null",
bioUpdated: "boolean",
logoUpdated: "boolean",
});
export type ApiV1PublisherProfileUpdateResponse =
(typeof ApiV1PublisherProfileUpdateResponseSchema)[inferred];
export const ApiV1PublisherDeleteResponseSchema = type({
ok: "true",
publisherId: "string",