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
+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.