mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
@@ -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.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
Generated
+21
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user