mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: add audited package hard delete (#3282)
This commit is contained in:
@@ -148,6 +148,7 @@ status|moderation-status <name>
|
||||
queue|moderation-queue
|
||||
reports
|
||||
triage-report <report-id>
|
||||
hard-delete <name>
|
||||
transfer <name>
|
||||
repair-name <name>
|
||||
migrations
|
||||
@@ -159,6 +160,8 @@ Examples:
|
||||
|
||||
```sh
|
||||
bun run admin -- packages status <name>
|
||||
bun run admin -- packages hard-delete <name> --owner <handle> --reason "<reason>" # dry-run
|
||||
bun run admin -- packages hard-delete <name> --owner <handle> --reason "<reason>" --apply --confirm "<token>" --yes
|
||||
bun run admin -- packages transfer <name> --to <owner> --reason "<reason>" # dry-run
|
||||
bun run admin -- packages transfer <name> --to <owner> --reason "<reason>" --apply
|
||||
bun run admin -- packages repair-name <name> --next-name <name> --reason "<reason>"
|
||||
@@ -228,5 +231,8 @@ only after admin auth succeeds.
|
||||
moderation hold, restores skills hidden by that hold, and writes an audit log.
|
||||
- `packages transfer` preserves the package row, stats, releases, and history;
|
||||
it changes the owner publisher.
|
||||
- `packages hard-delete` is admin-only, requires an already-soft-deleted package,
|
||||
exact owner handle, reason, and dry-run token, and permanently removes all
|
||||
package releases and related history.
|
||||
- `org delete` soft-deletes an empty org publisher and retains member rows for
|
||||
history; it refuses orgs with active skills or packages.
|
||||
|
||||
@@ -8161,6 +8161,81 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("package hard-delete dry-runs through the admin-only API", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const token = "hard-delete-package:@hxy91819/openclaw-tencent-provider:packages:1";
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
packageId: "packages:1",
|
||||
name: "openclaw-tencent-provider",
|
||||
ownerHandle: "hxy91819",
|
||||
displayName: "Tencent Cloud",
|
||||
runtimeId: "tencent",
|
||||
dryRun: true,
|
||||
deleted: false,
|
||||
confirmationToken: token,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/openclaw-tencent-provider/hard-delete", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({
|
||||
ownerHandle: "hxy91819",
|
||||
reason: "Owner-requested cleanup",
|
||||
dryRun: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
dryRun: true,
|
||||
deleted: false,
|
||||
confirmationToken: token,
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
(internal as unknown as { packages: Record<string, unknown> }).packages
|
||||
.hardDeleteForAdminInternal,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
name: "openclaw-tencent-provider",
|
||||
ownerHandle: "hxy91819",
|
||||
reason: "Owner-requested cleanup",
|
||||
dryRun: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("package hard-delete forbids non-admin API tokens", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error("should not hard-delete");
|
||||
});
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/demo/hard-delete", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({ ownerHandle: "openclaw", reason: "Cleanup" }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.text()).resolves.toBe("Admin role required.");
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skill rescan enqueues owner-authorized ClawScan jobs", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
@@ -15251,7 +15326,7 @@ describe("httpApiV1 handlers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status, await response.clone().text()).toBe(200);
|
||||
expect(response.status).toBe(200);
|
||||
expect(storageStore).toHaveBeenCalledTimes(3);
|
||||
expect(runAction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ApiV1PackageOfficialMigrationResponseSchema,
|
||||
ApiV1PackageModerationStatusResponseSchema,
|
||||
ApiV1PackageSecurityResponseSchema,
|
||||
PackageHardDeleteRequestSchema,
|
||||
PackageAppealResolveRequestSchema,
|
||||
PackageAppealRequestSchema,
|
||||
PackageOfficialMigrationUpsertRequestSchema,
|
||||
@@ -116,6 +117,7 @@ const internalRefs = internal as unknown as {
|
||||
searchForViewerInternal: unknown;
|
||||
listVersionsForViewerInternal: unknown;
|
||||
getPackageByNameInternal: unknown;
|
||||
hardDeleteForAdminInternal: unknown;
|
||||
getTrustedPublisherByPackageIdInternal: unknown;
|
||||
getVersionByNameForViewerInternal: unknown;
|
||||
getVersionSecurityByNameForViewerInternal: unknown;
|
||||
@@ -2663,6 +2665,40 @@ export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Reque
|
||||
const packageName = packageRoute.packageName;
|
||||
const packageSegments = packageRoute.rest;
|
||||
|
||||
if (packageSegments[0] === "hard-delete" && packageSegments.length === 1) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
const admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
|
||||
try {
|
||||
const body = parseArk(
|
||||
PackageHardDeleteRequestSchema,
|
||||
await request.json(),
|
||||
"Package hard-delete payload",
|
||||
) as {
|
||||
ownerHandle: string;
|
||||
reason: string;
|
||||
dryRun?: boolean;
|
||||
confirmationToken?: string;
|
||||
};
|
||||
const result = await runMutationRef(ctx, internalRefs.packages.hardDeleteForAdminInternal, {
|
||||
actorUserId: auth.userId,
|
||||
name: packageName,
|
||||
ownerHandle: body.ownerHandle,
|
||||
reason: body.reason,
|
||||
...(body.dryRun !== undefined ? { dryRun: body.dryRun } : {}),
|
||||
...(body.confirmationToken ? { confirmationToken: body.confirmationToken } : {}),
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return text("Invalid JSON", 400, rate.headers);
|
||||
return packageOperationErrorToResponse(error, rate.headers, "Package hard delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
if (packageSegments[0] === "rescan" && packageSegments.length === 1) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { hardDeleteForAdminInternal } from "./packages";
|
||||
|
||||
type Args = {
|
||||
actorUserId: string;
|
||||
name: string;
|
||||
ownerHandle: string;
|
||||
reason: string;
|
||||
dryRun?: boolean;
|
||||
confirmationToken?: string;
|
||||
};
|
||||
|
||||
type Handler = { _handler: (ctx: unknown, args: Args) => Promise<Record<string, unknown>> };
|
||||
const handler = (hardDeleteForAdminInternal as unknown as Handler)._handler;
|
||||
|
||||
function makeCtx(options: { softDeleted?: boolean; ownerHandle?: string } = {}) {
|
||||
const pkg = {
|
||||
_id: "packages:tencent",
|
||||
name: "openclaw-tencent-provider",
|
||||
normalizedName: "openclaw-tencent-provider",
|
||||
displayName: "Tencent Cloud",
|
||||
runtimeId: "tencent",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:hxy91819",
|
||||
softDeletedAt: options.softDeleted === false ? undefined : 1_000,
|
||||
};
|
||||
const publisher = {
|
||||
_id: "publishers:hxy91819",
|
||||
kind: "user",
|
||||
handle: options.ownerHandle ?? "hxy91819",
|
||||
linkedUserId: "users:owner",
|
||||
};
|
||||
const insert = vi.fn(async () => "auditLogs:1");
|
||||
const remove = vi.fn();
|
||||
const query = vi.fn((table: string) => ({
|
||||
withIndex: () => ({
|
||||
unique: async () => (table === "packages" ? pkg : null),
|
||||
collect: async () => [],
|
||||
}),
|
||||
}));
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: id, role: "admin" };
|
||||
if (id === "publishers:hxy91819") return publisher;
|
||||
return null;
|
||||
}),
|
||||
query,
|
||||
insert,
|
||||
delete: remove,
|
||||
patch: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
} as never;
|
||||
return { ctx, insert, remove };
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
actorUserId: "users:admin",
|
||||
name: "openclaw-tencent-provider",
|
||||
ownerHandle: "hxy91819",
|
||||
reason: "Free the stale package name for Tencent externalization",
|
||||
};
|
||||
|
||||
describe("hardDeleteForAdminInternal", () => {
|
||||
it("returns an exact token without mutating during dry-run", async () => {
|
||||
const { ctx, insert, remove } = makeCtx();
|
||||
await expect(handler(ctx, baseArgs)).resolves.toEqual({
|
||||
ok: true,
|
||||
packageId: "packages:tencent",
|
||||
name: "openclaw-tencent-provider",
|
||||
ownerHandle: "hxy91819",
|
||||
displayName: "Tencent Cloud",
|
||||
runtimeId: "tencent",
|
||||
dryRun: true,
|
||||
deleted: false,
|
||||
confirmationToken: "hard-delete-package:@hxy91819/openclaw-tencent-provider:packages:tencent",
|
||||
});
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the package to be soft-deleted and owner-qualified", async () => {
|
||||
await expect(handler(makeCtx({ softDeleted: false }).ctx, baseArgs)).rejects.toThrow(
|
||||
/must be soft-deleted/i,
|
||||
);
|
||||
await expect(handler(makeCtx().ctx, { ...baseArgs, ownerHandle: "tencent" })).rejects.toThrow(
|
||||
/owner does not match/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the exact token before deleting and auditing", async () => {
|
||||
const { ctx, insert, remove } = makeCtx();
|
||||
await expect(
|
||||
handler(ctx, { ...baseArgs, dryRun: false, confirmationToken: "wrong" }),
|
||||
).rejects.toThrow(/confirmation token must be/i);
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
|
||||
const token = "hard-delete-package:@hxy91819/openclaw-tencent-provider:packages:tencent";
|
||||
const result = await handler(ctx, {
|
||||
...baseArgs,
|
||||
dryRun: false,
|
||||
confirmationToken: token,
|
||||
});
|
||||
expect(result).toMatchObject({ dryRun: false, deleted: true });
|
||||
expect(remove).toHaveBeenCalledWith("packages:tencent");
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"auditLogs",
|
||||
expect.objectContaining({
|
||||
action: "package.hard_delete.requested",
|
||||
targetId: "packages:tencent",
|
||||
metadata: expect.objectContaining({
|
||||
ownerHandle: "hxy91819",
|
||||
reason: baseArgs.reason,
|
||||
source: "clawhub-admin",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+76
-1
@@ -617,6 +617,7 @@ const ownedPackageScanScopeValidator = v.optional(
|
||||
v.union(v.literal("ownerUserId"), v.literal("personalPublisher")),
|
||||
);
|
||||
const hardDeletePackageSourceValidator = v.union(
|
||||
v.literal("admin"),
|
||||
v.literal("account.delete"),
|
||||
v.literal("publisher.delete"),
|
||||
);
|
||||
@@ -5491,7 +5492,8 @@ async function hardDeletePackageDoc(
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
deletedAt: number;
|
||||
source: "account.delete" | "publisher.delete";
|
||||
source: "admin" | "account.delete" | "publisher.delete";
|
||||
reason?: string;
|
||||
},
|
||||
) {
|
||||
const releases = await ctx.db
|
||||
@@ -5574,6 +5576,7 @@ async function hardDeletePackageDoc(
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
source: params.source,
|
||||
reason: params.reason,
|
||||
releases: releases.length,
|
||||
reports: reports.length,
|
||||
appeals: appeals.length,
|
||||
@@ -5609,6 +5612,78 @@ export const hardDeletePackageInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const hardDeleteForAdminInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
name: v.string(),
|
||||
ownerHandle: v.string(),
|
||||
reason: v.string(),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
confirmationToken: v.optional(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 name = normalizePackageName(args.name);
|
||||
const ownerHandle = normalizePublisherHandle(args.ownerHandle);
|
||||
const reason = args.reason.trim();
|
||||
if (!name) throw new ConvexError("Package name required");
|
||||
if (!ownerHandle) throw new ConvexError("Owner handle required");
|
||||
if (!reason) throw new ConvexError("Reason is required");
|
||||
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, name);
|
||||
if (!pkg) throw new ConvexError("Package not found");
|
||||
if (!pkg.softDeletedAt) throw new ConvexError("Package must be soft-deleted first");
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
});
|
||||
if (normalizePublisherHandle(owner?.handle) !== ownerHandle) {
|
||||
throw new ConvexError("Package owner does not match --owner");
|
||||
}
|
||||
|
||||
const confirmationToken = `hard-delete-package:@${ownerHandle}/${pkg.normalizedName}:${pkg._id}`;
|
||||
const baseResult = {
|
||||
ok: true as const,
|
||||
packageId: pkg._id,
|
||||
name: pkg.normalizedName,
|
||||
ownerHandle,
|
||||
displayName: pkg.displayName,
|
||||
runtimeId: pkg.runtimeId ?? null,
|
||||
confirmationToken,
|
||||
};
|
||||
if (args.dryRun !== false) return { ...baseResult, dryRun: true, deleted: false };
|
||||
if (args.confirmationToken !== confirmationToken) {
|
||||
throw new ConvexError(`Confirmation token must be "${confirmationToken}"`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "package.hard_delete.requested",
|
||||
targetType: "package",
|
||||
targetId: pkg._id,
|
||||
metadata: {
|
||||
name: pkg.normalizedName,
|
||||
ownerHandle,
|
||||
reason,
|
||||
source: "clawhub-admin",
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
await hardDeletePackageDoc(ctx, pkg, {
|
||||
actorUserId: args.actorUserId,
|
||||
deletedAt: now,
|
||||
source: "admin",
|
||||
reason,
|
||||
});
|
||||
return { ...baseResult, dryRun: false, deleted: true };
|
||||
},
|
||||
});
|
||||
|
||||
function comparePackageRestoreLatestCandidates(
|
||||
family: Doc<"packages">["family"],
|
||||
a: Doc<"packageReleases">,
|
||||
|
||||
@@ -131,6 +131,7 @@ bun run admin -- plugins triage-report <report-id> --status open|confirmed|dismi
|
||||
|
||||
bun run admin -- plugins migrations [--phase <phase>]
|
||||
bun run admin -- plugins set-migration <bundled-plugin-id> --package <name>
|
||||
bun run admin -- plugins hard-delete <name> --owner <handle> --reason <text> [--apply --confirm <token> --yes] [--json]
|
||||
bun run admin -- plugins repair-name <name> --next-name <name> --reason <text> [--retire-target] [--owner <handle>] [--apply]
|
||||
bun run admin -- plugins trusted-publisher get <name>
|
||||
bun run admin -- plugins trusted-publisher set <name> --repository <owner/repo> --workflow-filename <file>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./commands/orgs.js";
|
||||
import {
|
||||
cmdDeletePackageTrustedPublisher,
|
||||
cmdHardDeletePackage,
|
||||
cmdListPackageMigrations,
|
||||
cmdListPackageReports,
|
||||
cmdModeratePackageRelease,
|
||||
@@ -594,6 +595,21 @@ function registerOrgCommands(command: Command) {
|
||||
}
|
||||
|
||||
function registerPluginGovernanceCommands(command: Command) {
|
||||
command
|
||||
.command("hard-delete")
|
||||
.description("Permanently delete one soft-deleted plugin package and all related history")
|
||||
.argument("<name>", "Plugin package name")
|
||||
.requiredOption("--owner <handle>", "Current owner publisher handle")
|
||||
.requiredOption("--reason <reason>", "Audit reason")
|
||||
.option("--apply", "Delete permanently; defaults to dry-run")
|
||||
.option("--confirm <token>", "Confirmation token returned by the dry-run")
|
||||
.option("--yes", "Skip confirmation for --apply")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdHardDeletePackage(opts, name, options, isInputAllowed());
|
||||
});
|
||||
|
||||
command
|
||||
.command("transfer")
|
||||
.description("Transfer a plugin package to another publisher without changing package stats")
|
||||
|
||||
@@ -19,13 +19,126 @@ vi.mock("../../../clawhub/src/cli/registry.js", () => registryMocks.moduleFactor
|
||||
vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdRepairPackageName, cmdRepairPackageRuntimeId, cmdTransferPackageOwner } =
|
||||
await import("./packages");
|
||||
const {
|
||||
cmdHardDeletePackage,
|
||||
cmdRepairPackageName,
|
||||
cmdRepairPackageRuntimeId,
|
||||
cmdTransferPackageOwner,
|
||||
} = await import("./packages");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cmdHardDeletePackage", () => {
|
||||
it("dry-runs an owner-qualified package by default", async () => {
|
||||
const confirmationToken =
|
||||
"hard-delete-package:@hxy91819/openclaw-tencent-provider:packages:tencent";
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "packages:tencent",
|
||||
name: "openclaw-tencent-provider",
|
||||
ownerHandle: "hxy91819",
|
||||
displayName: "Tencent Cloud",
|
||||
runtimeId: "tencent",
|
||||
dryRun: true,
|
||||
deleted: false,
|
||||
confirmationToken,
|
||||
});
|
||||
|
||||
const result = await cmdHardDeletePackage(
|
||||
makeGlobalOpts(),
|
||||
"openclaw-tencent-provider",
|
||||
{
|
||||
owner: "HXY91819",
|
||||
reason: "Free the stale package name for Tencent externalization",
|
||||
json: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dryRun: true,
|
||||
deleted: false,
|
||||
ownerHandle: "hxy91819",
|
||||
name: "openclaw-tencent-provider",
|
||||
});
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/openclaw-tencent-provider/hard-delete",
|
||||
token: "tkn",
|
||||
body: {
|
||||
ownerHandle: "hxy91819",
|
||||
reason: "Free the stale package name for Tencent externalization",
|
||||
dryRun: true,
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires owner, reason, and an apply confirmation token", async () => {
|
||||
await expect(
|
||||
cmdHardDeletePackage(makeGlobalOpts(), "demo", { reason: "Cleanup" }, false),
|
||||
).rejects.toThrow(/--owner required/i);
|
||||
await expect(
|
||||
cmdHardDeletePackage(makeGlobalOpts(), "demo", { owner: "openclaw" }, false),
|
||||
).rejects.toThrow(/--reason required/i);
|
||||
await expect(
|
||||
cmdHardDeletePackage(
|
||||
makeGlobalOpts(),
|
||||
"demo",
|
||||
{ owner: "openclaw", reason: "Cleanup", apply: true, yes: true },
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow(/--confirm required/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies with the exact token and disables retries", async () => {
|
||||
const confirmationToken =
|
||||
"hard-delete-package:@hxy91819/openclaw-tencent-provider:packages:tencent";
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "packages:tencent",
|
||||
name: "openclaw-tencent-provider",
|
||||
ownerHandle: "hxy91819",
|
||||
displayName: "Tencent Cloud",
|
||||
runtimeId: "tencent",
|
||||
dryRun: false,
|
||||
deleted: true,
|
||||
confirmationToken,
|
||||
});
|
||||
await cmdHardDeletePackage(
|
||||
makeGlobalOpts(),
|
||||
"openclaw-tencent-provider",
|
||||
{
|
||||
owner: "hxy91819",
|
||||
reason: "Cleanup",
|
||||
apply: true,
|
||||
confirm: confirmationToken,
|
||||
yes: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
retryCount: 0,
|
||||
body: {
|
||||
ownerHandle: "hxy91819",
|
||||
reason: "Cleanup",
|
||||
dryRun: false,
|
||||
confirmationToken,
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdRepairPackageName", () => {
|
||||
it("defaults to a dry run", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
|
||||
@@ -5,10 +5,17 @@ import {
|
||||
} from "../../../clawhub/src/cli/commands/moderationPlan.js";
|
||||
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
|
||||
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
|
||||
import { createCrabLoader, fail, formatError } from "../../../clawhub/src/cli/ui.js";
|
||||
import {
|
||||
createCrabLoader,
|
||||
fail,
|
||||
formatError,
|
||||
isInteractive,
|
||||
promptConfirm,
|
||||
} from "../../../clawhub/src/cli/ui.js";
|
||||
import { apiRequest, registryUrl } from "../../../clawhub/src/http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1PackageHardDeleteResponseSchema,
|
||||
ApiV1PackageModerationQueueResponseSchema,
|
||||
ApiV1PackageOfficialMigrationListResponseSchema,
|
||||
ApiV1PackageOfficialMigrationResponseSchema,
|
||||
@@ -114,6 +121,74 @@ type PackageTransferOwnerOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageHardDeleteOptions = {
|
||||
owner?: string;
|
||||
reason?: string;
|
||||
apply?: boolean;
|
||||
confirm?: string;
|
||||
json?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
export async function cmdHardDeletePackage(
|
||||
opts: GlobalOpts,
|
||||
packageName: string,
|
||||
options: PackageHardDeleteOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const name = normalizePackageNameOrFail(packageName).toLowerCase();
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "").toLowerCase();
|
||||
const reason = options.reason?.trim();
|
||||
if (!ownerHandle) fail("--owner required");
|
||||
if (!reason) fail("--reason required");
|
||||
const dryRun = options.apply !== true;
|
||||
const confirmationToken = options.confirm?.trim();
|
||||
if (!dryRun && !confirmationToken) fail("--confirm required when using --apply");
|
||||
|
||||
if (!dryRun && !options.yes) {
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false;
|
||||
if (!allowPrompt) fail("Pass --yes (no input)");
|
||||
const confirmed = await promptConfirm(
|
||||
`Permanently hard-delete @${ownerHandle}/${name} and all related history? (cannot be undone)`,
|
||||
);
|
||||
if (!confirmed) return undefined;
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json
|
||||
? null
|
||||
: createCrabLoader(`${dryRun ? "Planning hard delete for" : "Hard-deleting"} ${name}`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.packages}/${encodeURIComponent(name)}/hard-delete`,
|
||||
token,
|
||||
...(dryRun ? {} : { retryCount: 0 }),
|
||||
body: {
|
||||
ownerHandle,
|
||||
reason,
|
||||
dryRun,
|
||||
...(confirmationToken ? { confirmationToken } : {}),
|
||||
},
|
||||
},
|
||||
ApiV1PackageHardDeleteResponseSchema,
|
||||
);
|
||||
spinner?.succeed(
|
||||
result.deleted
|
||||
? `Hard-deleted @${result.ownerHandle}/${result.name}`
|
||||
: `Dry run OK for @${result.ownerHandle}/${result.name}: pass --apply --confirm ${result.confirmationToken}`,
|
||||
);
|
||||
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 cmdSetPackageTrustedPublisher(
|
||||
opts: GlobalOpts,
|
||||
packageName: string,
|
||||
|
||||
@@ -702,6 +702,28 @@ export const ApiV1PackageTransferResponseSchema = type({
|
||||
});
|
||||
export type ApiV1PackageTransferResponse = (typeof ApiV1PackageTransferResponseSchema)[inferred];
|
||||
|
||||
export const PackageHardDeleteRequestSchema = type({
|
||||
ownerHandle: "string",
|
||||
reason: "string",
|
||||
dryRun: "boolean?",
|
||||
confirmationToken: "string?",
|
||||
});
|
||||
export type PackageHardDeleteRequest = (typeof PackageHardDeleteRequestSchema)[inferred];
|
||||
|
||||
export const ApiV1PackageHardDeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
packageId: "string",
|
||||
name: "string",
|
||||
ownerHandle: "string",
|
||||
displayName: "string",
|
||||
runtimeId: "string|null?",
|
||||
dryRun: "boolean",
|
||||
deleted: "boolean",
|
||||
confirmationToken: "string",
|
||||
});
|
||||
export type ApiV1PackageHardDeleteResponse =
|
||||
(typeof ApiV1PackageHardDeleteResponseSchema)[inferred];
|
||||
|
||||
export const PackageRepairNameRequestSchema = type({
|
||||
nextName: "string",
|
||||
retireTarget: "boolean?",
|
||||
|
||||
Vendored
+19
@@ -913,6 +913,25 @@ export declare const ApiV1PackageTransferResponseSchema: import("arktype/interna
|
||||
isOfficial: boolean;
|
||||
}, {}>;
|
||||
export type ApiV1PackageTransferResponse = (typeof ApiV1PackageTransferResponseSchema)[inferred];
|
||||
export declare const PackageHardDeleteRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ownerHandle: string;
|
||||
reason: string;
|
||||
dryRun?: boolean | undefined;
|
||||
confirmationToken?: string | undefined;
|
||||
}, {}>;
|
||||
export type PackageHardDeleteRequest = (typeof PackageHardDeleteRequestSchema)[inferred];
|
||||
export declare const ApiV1PackageHardDeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
packageId: string;
|
||||
name: string;
|
||||
ownerHandle: string;
|
||||
displayName: string;
|
||||
runtimeId?: string | null | undefined;
|
||||
dryRun: boolean;
|
||||
deleted: boolean;
|
||||
confirmationToken: string;
|
||||
}, {}>;
|
||||
export type ApiV1PackageHardDeleteResponse = (typeof ApiV1PackageHardDeleteResponseSchema)[inferred];
|
||||
export declare const PackageRepairNameRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
nextName: string;
|
||||
retireTarget?: boolean | undefined;
|
||||
|
||||
Vendored
+17
@@ -584,6 +584,23 @@ export const ApiV1PackageTransferResponseSchema = type({
|
||||
channel: PackageChannelSchema,
|
||||
isOfficial: "boolean",
|
||||
});
|
||||
export const PackageHardDeleteRequestSchema = type({
|
||||
ownerHandle: "string",
|
||||
reason: "string",
|
||||
dryRun: "boolean?",
|
||||
confirmationToken: "string?",
|
||||
});
|
||||
export const ApiV1PackageHardDeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
packageId: "string",
|
||||
name: "string",
|
||||
ownerHandle: "string",
|
||||
displayName: "string",
|
||||
runtimeId: "string|null?",
|
||||
dryRun: "boolean",
|
||||
deleted: "boolean",
|
||||
confirmationToken: "string",
|
||||
});
|
||||
export const PackageRepairNameRequestSchema = type({
|
||||
nextName: "string",
|
||||
retireTarget: "boolean?",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -738,6 +738,28 @@ export const ApiV1PackageTransferResponseSchema = type({
|
||||
});
|
||||
export type ApiV1PackageTransferResponse = (typeof ApiV1PackageTransferResponseSchema)[inferred];
|
||||
|
||||
export const PackageHardDeleteRequestSchema = type({
|
||||
ownerHandle: "string",
|
||||
reason: "string",
|
||||
dryRun: "boolean?",
|
||||
confirmationToken: "string?",
|
||||
});
|
||||
export type PackageHardDeleteRequest = (typeof PackageHardDeleteRequestSchema)[inferred];
|
||||
|
||||
export const ApiV1PackageHardDeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
packageId: "string",
|
||||
name: "string",
|
||||
ownerHandle: "string",
|
||||
displayName: "string",
|
||||
runtimeId: "string|null?",
|
||||
dryRun: "boolean",
|
||||
deleted: "boolean",
|
||||
confirmationToken: "string",
|
||||
});
|
||||
export type ApiV1PackageHardDeleteResponse =
|
||||
(typeof ApiV1PackageHardDeleteResponseSchema)[inferred];
|
||||
|
||||
export const PackageRepairNameRequestSchema = type({
|
||||
nextName: "string",
|
||||
retireTarget: "boolean?",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { parseArk } from "./ark";
|
||||
import { DocsLinks, openClawDocsUrl } from "./docsLinks";
|
||||
import {
|
||||
ApiV1PackageHardDeleteResponseSchema,
|
||||
ApiV1PackageVersionResponseSchema,
|
||||
ApiV1PackagePublishResponseSchema,
|
||||
getPackageScopeOwnerMismatch,
|
||||
@@ -24,6 +25,26 @@ import {
|
||||
} from "./schemas";
|
||||
|
||||
describe("clawhub-schema", () => {
|
||||
it("parses package hard-delete responses", () => {
|
||||
const result = parseArk(
|
||||
ApiV1PackageHardDeleteResponseSchema,
|
||||
{
|
||||
ok: true,
|
||||
packageId: "packages:tencent",
|
||||
name: "openclaw-tencent-provider",
|
||||
ownerHandle: "hxy91819",
|
||||
displayName: "Tencent Cloud",
|
||||
runtimeId: "tencent",
|
||||
dryRun: true,
|
||||
deleted: false,
|
||||
confirmationToken:
|
||||
"hard-delete-package:@hxy91819/openclaw-tencent-provider:packages:tencent",
|
||||
},
|
||||
"Package hard-delete response",
|
||||
);
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("parses skill hard-delete responses", () => {
|
||||
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
|
||||
const response = parseArk(
|
||||
|
||||
Reference in New Issue
Block a user