feat(cli): list, get and restore trashed items with wmill trash (#11125)

* feat(cli): list, get and restore trashed items from the CLI

* docs(cli): tell agents a sync push deletion is restorable with wmill trash

* refactor(cli): share the ApiError formatting and type trash flags as integers
This commit is contained in:
Ruben Fiszel
2026-09-14 20:06:56 +00:00
committed by GitHub
parent 91e6dc39ce
commit a95e950529
13 changed files with 471 additions and 80 deletions
+147
View File
@@ -293,6 +293,108 @@ paths:
items:
$ref: "#/components/schemas/AuditLog"
/w/{workspace}/trash/list:
get:
summary: list the workspace trashbin (requires admin privilege)
operationId: listTrash
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: item_kind
in: query
description: >
only return items of this kind: script, flow, app, schedule, variable,
resource, or a trigger kind such as http_trigger
schema:
type: string
- name: page
in: query
description: which page to return (starts at 0, default 0)
schema:
type: integer
- name: per_page
in: query
description: number of items to return for a given page (default 100, max 1000)
schema:
type: integer
responses:
"200":
description: the trashed items, most recently deleted first
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TrashItem"
/w/{workspace}/trash/get/{id}:
get:
summary: get a trashed item with the data it was deleted with (requires admin privilege)
operationId: getTrashItem
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: the trashed item
content:
application/json:
schema:
$ref: "#/components/schemas/TrashItemWithData"
/w/{workspace}/trash/restore/{id}:
post:
summary: restore a trashed item to its path (requires admin privilege)
operationId: restoreTrashItem
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: item restored
content:
text/plain:
schema:
type: string
/w/{workspace}/trash/delete/{id}:
delete:
summary: permanently delete a trashed item (requires admin privilege)
operationId: permanentlyDeleteTrashItem
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/PathId"
responses:
"200":
description: item permanently deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/trash/empty:
post:
summary: permanently delete every item in the workspace trashbin (requires admin privilege)
operationId: emptyTrash
tags:
- trash
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: trashbin emptied
content:
text/plain:
schema:
type: string
/auth/login:
post:
security: []
@@ -30015,6 +30117,51 @@ components:
- operation
- action_kind
TrashItem:
type: object
properties:
id:
type: integer
format: int64
workspace_id:
type: string
item_kind:
type: string
description: script, flow, app, schedule, variable, resource, or a trigger kind such as http_trigger
item_path:
type: string
deleted_by:
type: string
deleted_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
description: when the item is permanently deleted unless restored first
required:
- id
- workspace_id
- item_kind
- item_path
- deleted_by
- deleted_at
- expires_at
TrashItemWithData:
allOf:
- $ref: "#/components/schemas/TrashItem"
- type: object
properties:
item_data:
type: object
additionalProperties: true
description: >
the deleted rows as they were stored; the shape depends on the kind, and a
secret variable's value stays encrypted
required:
- item_data
MainArgSignature:
type: object
properties:
+1 -1
View File
@@ -6611,7 +6611,7 @@ export async function push(
if (deletedSecretBearing.length > 0) {
log.info(
colors.gray(
`${describeSecretBearingChanges(deletedSecretBearing)} deleted. The workspace trashbin keeps a deleted item for three days; a workspace admin can restore it from Workspace settings -> Trashbin.`,
`${describeSecretBearingChanges(deletedSecretBearing)} deleted. The workspace trashbin keeps a deleted item for three days; a workspace admin can restore it with \`wmill trash list\` and \`wmill trash restore <id>\`, or from Workspace settings -> Trashbin.`,
),
);
}
+147
View File
@@ -0,0 +1,147 @@
import { GlobalOptions } from "../../types.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { Command } from "@cliffy/command";
import { Table } from "@cliffy/table";
import { colors } from "@cliffy/ansi/colors";
import * as log from "../../core/log.ts";
import { mergeConfigWithConfigFile } from "../../core/conf.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { apiErrorMessage, formatTimestamp } from "../../utils/utils.ts";
async function list(
opts: GlobalOptions & {
json?: boolean;
kind?: string;
page?: number;
limit?: number;
}
) {
if (opts.json) log.setSilent(true);
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (opts.page !== undefined && opts.page < 1) {
throw new Error("--page starts at 1");
}
const items = await wmill.listTrash({
workspace: workspace.workspaceId,
itemKind: opts.kind,
// The trash endpoint counts pages from 0, unlike the API's other list
// endpoints whose `page` starts at 1; the flag counts from 1 like those.
page: opts.page === undefined ? undefined : opts.page - 1,
perPage: opts.limit,
});
if (opts.json) {
console.log(JSON.stringify(items));
return;
}
if (items.length === 0) {
log.info("No trashed items found.");
return;
}
new Table()
.header(["ID", "Kind", "Path", "Deleted by", "Deleted at", "Expires at"])
.padding(2)
.border(true)
.body(
items.map((item) => [
String(item.id),
item.item_kind,
item.item_path,
item.deleted_by,
formatTimestamp(item.deleted_at),
formatTimestamp(item.expires_at),
])
)
.render();
log.info(
colors.gray(
"`wmill trash get <id>` shows what an item held, `wmill trash restore <id>` puts it back."
)
);
}
async function get(opts: GlobalOptions & { json?: boolean }, id: number) {
if (opts.json) log.setSilent(true);
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const item = await wmill.getTrashItem({
workspace: workspace.workspaceId,
id,
});
if (opts.json) {
console.log(JSON.stringify(item));
return;
}
console.log(colors.bold("ID:") + " " + item.id);
console.log(colors.bold("Kind:") + " " + item.item_kind);
console.log(colors.bold("Path:") + " " + item.item_path);
console.log(colors.bold("Deleted by:") + " " + item.deleted_by);
console.log(colors.bold("Deleted at:") + " " + formatTimestamp(item.deleted_at));
console.log(colors.bold("Expires at:") + " " + formatTimestamp(item.expires_at));
console.log(colors.bold("Data:"));
console.log(JSON.stringify(item.item_data, null, 2));
}
async function restore(opts: GlobalOptions, ...ids: number[]) {
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
let failed = 0;
for (const id of ids) {
try {
const message = await wmill.restoreTrashItem({
workspace: workspace.workspaceId,
id,
});
log.info(colors.green(message));
} catch (e) {
failed += 1;
log.error(
`Could not restore trash item ${id}: ${apiErrorMessage(e) ?? String(e)}`
);
}
}
if (failed > 0) {
process.exitCode = 1;
}
}
const command = new Command()
.description(
"List, inspect and restore items deleted in the last three days (requires admin)"
)
.option("--json", "Output as JSON (for piping to jq)")
.option(
"--kind <kind:string>",
"Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger"
)
.option("--limit <limit:integer>", "Number of items to return (default 100, max 1000)")
.option("--page <page:integer>", "Page to return, starting at 1")
.action(list as any)
.command("list", "List trashed items, most recently deleted first")
.option("--json", "Output as JSON (for piping to jq)")
.option(
"--kind <kind:string>",
"Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger"
)
.option("--limit <limit:integer>", "Number of items to return (default 100, max 1000)")
.option("--page <page:integer>", "Page to return, starting at 1")
.action(list as any)
.command("get", "Show a trashed item and the data it was deleted with")
.arguments("<id:integer>")
.option("--json", "Output as JSON (for piping to jq)")
.action(get as any)
.command("restore", "Put trashed items back at their paths")
.arguments("<ids...:integer>")
.action(restore as any);
export default command;
+2
View File
@@ -165,6 +165,8 @@ No CI workflow runs \`wmill sync push\` automatically, so deploy directly from t
- \`wmill sync push --dry-run\` to preview.
- \`wmill sync push\` to apply.
A push deletes remote items that have no local file. They land in the workspace trashbin for three days: \`wmill trash list\` shows them and \`wmill trash restore <id>\` puts one back (both need a workspace admin).
### In both cases
Only deploy when the user explicitly asks to deploy, publish, push, or ship — not when they say "run", "try", or "test". For testing local edits use the per-entity \`preview\` commands (\`wmill script preview\`, \`wmill flow preview\`) — they don't deploy.
+21
View File
@@ -7720,6 +7720,27 @@ Manage API tokens
- \`--expiration <expiration:string>\` - Token expiration (ISO 8601 timestamp)
- \`token delete <token_prefix:string>\` - Delete a token by its prefix
### trash
List, inspect and restore items deleted in the last three days (requires admin)
**Options:**
- \`--json\` - Output as JSON (for piping to jq)
- \`--kind <kind:string>\` - Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger
- \`--limit <limit:integer>\` - Number of items to return (default 100, max 1000)
- \`--page <page:integer>\` - Page to return, starting at 1
**Subcommands:**
- \`trash list\` - List trashed items, most recently deleted first
- \`--json\` - Output as JSON (for piping to jq)
- \`--kind <kind:string>\` - Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger
- \`--limit <limit:integer>\` - Number of items to return (default 100, max 1000)
- \`--page <page:integer>\` - Page to return, starting at 1
- \`trash get <id:integer>\` - Show a trashed item and the data it was deleted with
- \`--json\` - Output as JSON (for piping to jq)
- \`trash restore <ids...:integer>\` - Put trashed items back at their paths
### trigger
trigger related commands
+6 -9
View File
@@ -29,7 +29,7 @@ import lint from "./commands/lint/lint.ts";
import dev from "./commands/dev/dev.ts";
import { GlobalOptions } from "./types.ts";
import { OpenAPI } from "../gen/index.ts";
import { getHeaders } from "./utils/utils.ts";
import { apiErrorMessage, getHeaders } from "./utils/utils.ts";
import { detectAuthGatewayChallenge } from "./utils/http_guards.ts";
import { setShowDiffs } from "./core/conf.ts";
import { markRequestsAsCliClient } from "./core/client.ts";
@@ -48,6 +48,7 @@ import job from "./commands/job/job.ts";
import group from "./commands/group/group.ts";
import audit from "./commands/audit/audit.ts";
import token from "./commands/token/token.ts";
import trash from "./commands/trash/trash.ts";
import generateMetadata from "./commands/generate-metadata/generate-metadata.ts";
import docs from "./commands/docs/docs.ts";
import config from "./commands/config/config.ts";
@@ -214,6 +215,7 @@ const command = new Command()
.command("group", group)
.command("audit", audit)
.command("token", token)
.command("trash", trash)
.command("generate-metadata", generateMetadata)
.command("docs", docs)
.command("config", config)
@@ -321,14 +323,9 @@ async function main() {
await command.parse(args);
} catch (e) {
if (e && typeof e === "object" && "name" in e && e.name === "ApiError") {
const body = (e as any).body;
let bodyStr = typeof body === "object" && body !== null ? JSON.stringify(body) : String(body ?? "");
// Strip backend source file references like (flows.rs:1400) or @scripts.rs:123:45
bodyStr = bodyStr.replace(/\s*[@(]\w+\.rs:\d+[:\d]*\)?/g, "");
log.error(
"Server failed. " + (e as any).statusText + ": " + bodyStr
);
const apiError = apiErrorMessage(e);
if (apiError !== undefined) {
log.error("Server failed. " + apiError);
} else if (e instanceof Error) {
log.error(e.message);
} else if (e !== undefined && e !== null) {
+17
View File
@@ -353,6 +353,23 @@ export function formatTimestamp(ts: string): string {
return new Date(ts).toISOString().replace("T", " ").substring(0, 19);
}
/**
* "<status text>: <body>" for an error thrown by the generated API client,
* undefined for anything else. Backend source references such as
* `(flows.rs:1400)` are stripped from the body.
*/
export function apiErrorMessage(e: unknown): string | undefined {
if (!(e && typeof e === "object" && "name" in e && e.name === "ApiError")) {
return undefined;
}
const { body, statusText } = e as { body?: unknown; statusText?: string };
const bodyStr =
typeof body === "object" && body !== null
? JSON.stringify(body)
: String(body ?? "");
return statusText + ": " + bodyStr.replace(/\s*[@(]\w+\.rs:\d+[:\d]*\)?/g, "");
}
/**
* Validate that required arguments are present when no -d data was provided.
* Fetches the schema from the API and checks required fields.
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, describe } from "bun:test";
import { withTestBackend } from "./test_backend.ts";
import { setupWorkspaceProfile, ensureFolder } from "./new_commands_helpers.ts";
describe("trash command", () => {
test("lists, shows and restores a deleted variable", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await ensureFolder(backend, "test");
const ws = backend.workspace;
const path = `f/test/trash_${Date.now()}`;
const api = (route: string, init: RequestInit = {}) =>
backend.apiRequest!(`/api/w/${ws}/${route}`, {
headers: { "Content-Type": "application/json" },
...init,
});
let resp = await api("variables/create", {
method: "POST",
body: JSON.stringify({ path, value: "kept", is_secret: false, description: "" }),
});
expect(resp.status).toBeLessThan(300);
await resp.text();
resp = await api(`variables/delete/${path}`, { method: "DELETE" });
expect(resp.status).toBeLessThan(300);
await resp.text();
const list = await backend.runCLICommand(
["trash", "list", "--json", "--kind", "variable"],
tempDir
);
expect(list.code).toBe(0);
const item = JSON.parse(list.stdout).find((i: any) => i.item_path === path);
expect(item).toBeDefined();
expect(item.item_kind).toBe("variable");
const get = await backend.runCLICommand(
["trash", "get", "--json", String(item.id)],
tempDir
);
expect(get.code).toBe(0);
expect(JSON.parse(get.stdout).item_data.row.value).toBe("kept");
// A bogus second id: the first restore must still go through, and the
// failure must show in the exit code.
const restore = await backend.runCLICommand(
["trash", "restore", String(item.id), "999999999"],
tempDir
);
expect(restore.code).toBe(1);
expect(restore.stdout).toContain(`variable '${path}' restored`);
expect(restore.stderr).toContain("999999999");
resp = await api(`variables/get/${path}`);
expect(resp.status).toBe(200);
expect((await resp.json()).value).toBe("kept");
const after = await backend.runCLICommand(
["trash", "list", "--json", "--kind", "variable"],
tempDir
);
expect(after.code).toBe(0);
expect(JSON.parse(after.stdout).some((i: any) => i.item_path === path)).toBe(false);
});
});
});
@@ -7,7 +7,7 @@
import Row from '$lib/components/table/Row.svelte'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { type TrashItem, TrashService } from '$lib/services/trashService'
import { type TrashItem, TrashService } from '$lib/gen'
import {
Trash2,
RotateCcw,
-69
View File
@@ -1,69 +0,0 @@
import { OpenAPI } from '$lib/gen/core/OpenAPI'
import { request as __request } from '$lib/gen/core/request'
export type TrashItem = {
id: number
workspace_id: string
item_kind: string
item_path: string
deleted_by: string
deleted_at: string
expires_at: string
}
export class TrashService {
public static listTrash(data: {
workspace: string
itemKind?: string
page?: number
perPage?: number
}): Promise<TrashItem[]> {
return __request(OpenAPI, {
method: 'GET',
url: '/w/{workspace}/trash/list',
path: {
workspace: data.workspace
},
query: {
item_kind: data.itemKind,
page: data.page,
per_page: data.perPage
}
})
}
public static restoreTrashItem(data: { workspace: string; id: number }): Promise<string> {
return __request(OpenAPI, {
method: 'POST',
url: '/w/{workspace}/trash/restore/{id}',
path: {
workspace: data.workspace,
id: data.id
}
})
}
public static permanentlyDeleteTrashItem(data: {
workspace: string
id: number
}): Promise<string> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/w/{workspace}/trash/delete/{id}',
path: {
workspace: data.workspace,
id: data.id
}
})
}
public static emptyTrash(data: { workspace: string }): Promise<string> {
return __request(OpenAPI, {
method: 'POST',
url: '/w/{workspace}/trash/empty',
path: {
workspace: data.workspace
}
})
}
}
@@ -668,6 +668,27 @@ Manage API tokens
- `--expiration <expiration:string>` - Token expiration (ISO 8601 timestamp)
- `token delete <token_prefix:string>` - Delete a token by its prefix
### trash
List, inspect and restore items deleted in the last three days (requires admin)
**Options:**
- `--json` - Output as JSON (for piping to jq)
- `--kind <kind:string>` - Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger
- `--limit <limit:integer>` - Number of items to return (default 100, max 1000)
- `--page <page:integer>` - Page to return, starting at 1
**Subcommands:**
- `trash list` - List trashed items, most recently deleted first
- `--json` - Output as JSON (for piping to jq)
- `--kind <kind:string>` - Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger
- `--limit <limit:integer>` - Number of items to return (default 100, max 1000)
- `--page <page:integer>` - Page to return, starting at 1
- `trash get <id:integer>` - Show a trashed item and the data it was deleted with
- `--json` - Output as JSON (for piping to jq)
- `trash restore <ids...:integer>` - Put trashed items back at their paths
### trigger
trigger related commands
+21
View File
@@ -3864,6 +3864,27 @@ Manage API tokens
- \`--expiration <expiration:string>\` - Token expiration (ISO 8601 timestamp)
- \`token delete <token_prefix:string>\` - Delete a token by its prefix
### trash
List, inspect and restore items deleted in the last three days (requires admin)
**Options:**
- \`--json\` - Output as JSON (for piping to jq)
- \`--kind <kind:string>\` - Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger
- \`--limit <limit:integer>\` - Number of items to return (default 100, max 1000)
- \`--page <page:integer>\` - Page to return, starting at 1
**Subcommands:**
- \`trash list\` - List trashed items, most recently deleted first
- \`--json\` - Output as JSON (for piping to jq)
- \`--kind <kind:string>\` - Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger
- \`--limit <limit:integer>\` - Number of items to return (default 100, max 1000)
- \`--page <page:integer>\` - Page to return, starting at 1
- \`trash get <id:integer>\` - Show a trashed item and the data it was deleted with
- \`--json\` - Output as JSON (for piping to jq)
- \`trash restore <ids...:integer>\` - Put trashed items back at their paths
### trigger
trigger related commands
@@ -673,6 +673,27 @@ Manage API tokens
- `--expiration <expiration:string>` - Token expiration (ISO 8601 timestamp)
- `token delete <token_prefix:string>` - Delete a token by its prefix
### trash
List, inspect and restore items deleted in the last three days (requires admin)
**Options:**
- `--json` - Output as JSON (for piping to jq)
- `--kind <kind:string>` - Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger
- `--limit <limit:integer>` - Number of items to return (default 100, max 1000)
- `--page <page:integer>` - Page to return, starting at 1
**Subcommands:**
- `trash list` - List trashed items, most recently deleted first
- `--json` - Output as JSON (for piping to jq)
- `--kind <kind:string>` - Only items of this kind: script, flow, app, schedule, variable, resource or a trigger kind such as http_trigger
- `--limit <limit:integer>` - Number of items to return (default 100, max 1000)
- `--page <page:integer>` - Page to return, starting at 1
- `trash get <id:integer>` - Show a trashed item and the data it was deleted with
- `--json` - Output as JSON (for piping to jq)
- `trash restore <ids...:integer>` - Put trashed items back at their paths
### trigger
trigger related commands