mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat(cli): add job, group, audit, token commands and schedule enable/disable (#8581)
* feat(cli): add job, group, audit, token commands and schedule enable/disable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(cli): regenerate system prompts after new commands Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): address PR review feedback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(cli): regenerate system prompts after review fixes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(cli): extract shared formatTimestamp util and remove unused resolveWorkspace in token Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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 { formatTimestamp } from "../../utils/utils.ts";
|
||||
|
||||
async function list(
|
||||
opts: GlobalOptions & {
|
||||
json?: boolean;
|
||||
username?: string;
|
||||
operation?: string;
|
||||
actionKind?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
limit?: number;
|
||||
}
|
||||
) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const logs = await wmill.listAuditLogs({
|
||||
workspace: workspace.workspaceId,
|
||||
username: opts.username,
|
||||
operation: opts.operation,
|
||||
actionKind: opts.actionKind as any,
|
||||
before: opts.before,
|
||||
after: opts.after,
|
||||
perPage: opts.limit ?? 30,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(logs));
|
||||
} else {
|
||||
if (logs.length === 0) {
|
||||
log.info("No audit logs found.");
|
||||
return;
|
||||
}
|
||||
new Table()
|
||||
.header(["ID", "Timestamp", "Username", "Operation", "Action", "Resource"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
logs.map((l) => [
|
||||
String(l.id),
|
||||
formatTimestamp(l.timestamp),
|
||||
l.username,
|
||||
l.operation,
|
||||
l.action_kind,
|
||||
l.resource ?? "-",
|
||||
])
|
||||
)
|
||||
.render();
|
||||
}
|
||||
}
|
||||
|
||||
async function get(
|
||||
opts: GlobalOptions & { json?: boolean },
|
||||
id: string
|
||||
) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const auditLog = await wmill.getAuditLog({
|
||||
workspace: workspace.workspaceId,
|
||||
id: parseInt(id, 10),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(auditLog));
|
||||
} else {
|
||||
console.log(colors.bold("ID:") + " " + auditLog.id);
|
||||
console.log(colors.bold("Timestamp:") + " " + formatTimestamp(auditLog.timestamp));
|
||||
console.log(colors.bold("Username:") + " " + auditLog.username);
|
||||
console.log(colors.bold("Operation:") + " " + auditLog.operation);
|
||||
console.log(colors.bold("Action Kind:") + " " + auditLog.action_kind);
|
||||
console.log(colors.bold("Resource:") + " " + (auditLog.resource ?? "-"));
|
||||
if (auditLog.parameters && Object.keys(auditLog.parameters).length > 0) {
|
||||
console.log(colors.bold("Parameters:"));
|
||||
console.log(JSON.stringify(auditLog.parameters, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const auditListOptions = (cmd: Command) =>
|
||||
cmd
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.option("--username <username:string>", "Filter by username")
|
||||
.option("--operation <operation:string>", "Filter by operation (exact or prefix)")
|
||||
.option("--action-kind <actionKind:string>", "Filter by action kind (Create, Update, Delete, Execute)")
|
||||
.option("--before <before:string>", "Filter events before this timestamp")
|
||||
.option("--after <after:string>", "Filter events after this timestamp")
|
||||
.option("--limit <limit:number>", "Number of entries to return (default 30, max 100)");
|
||||
|
||||
const command = auditListOptions(new Command()
|
||||
.description("View audit logs (requires admin)"))
|
||||
.action(list as any)
|
||||
.command("list", auditListOptions(new Command().description("List audit log entries")))
|
||||
.action(list as any)
|
||||
.command("get", "Get a specific audit log entry")
|
||||
.arguments("<id:string>")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(get as any);
|
||||
|
||||
export default command;
|
||||
@@ -570,6 +570,71 @@ export async function bootstrap(
|
||||
writeFileSync(flowYamlPath, newFlowDefinitionYaml, { flag: "wx", encoding: "utf-8" });
|
||||
}
|
||||
|
||||
async function history(
|
||||
opts: GlobalOptions & { json?: boolean },
|
||||
flowPath: string
|
||||
) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const versions = await wmill.getFlowHistory({
|
||||
workspace: workspace.workspaceId,
|
||||
path: flowPath,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(versions));
|
||||
} else {
|
||||
if (versions.length === 0) {
|
||||
log.info("No version history found for " + flowPath);
|
||||
return;
|
||||
}
|
||||
new Table()
|
||||
.header(["Version", "Created At", "Deployment Message"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
versions.map((v) => [
|
||||
String(v.id),
|
||||
new Date(v.created_at).toISOString().replace("T", " ").substring(0, 19),
|
||||
v.deployment_msg ?? "-",
|
||||
])
|
||||
)
|
||||
.render();
|
||||
}
|
||||
}
|
||||
|
||||
async function showVersion(
|
||||
opts: GlobalOptions & { json?: boolean },
|
||||
flowPath: string,
|
||||
version: string
|
||||
) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const flow = await wmill.getFlowVersion({
|
||||
workspace: workspace.workspaceId,
|
||||
path: flowPath,
|
||||
version: parseInt(version, 10),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(flow));
|
||||
} else {
|
||||
console.log(colors.bold("Path:") + " " + flow.path);
|
||||
console.log(colors.bold("Summary:") + " " + (flow.summary ?? "-"));
|
||||
console.log(colors.bold("Description:") + " " + (flow.description ?? "-"));
|
||||
console.log(colors.bold("Schema:"));
|
||||
console.log(JSON.stringify(flow.schema, null, 2));
|
||||
console.log(colors.bold("Value:"));
|
||||
console.log(JSON.stringify(flow.value, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.description("flow related commands")
|
||||
.option("--show-archived", "Enable archived flows in output")
|
||||
@@ -643,6 +708,14 @@ const command = new Command()
|
||||
.arguments("<flow_path:string>")
|
||||
.option("--summary <summary:string>", "flow summary")
|
||||
.option("--description <description:string>", "flow description")
|
||||
.action(bootstrap as any);
|
||||
.action(bootstrap as any)
|
||||
.command("history", "Show version history for a flow")
|
||||
.arguments("<path:string>")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(history as any)
|
||||
.command("show-version", "Show a specific version of a flow")
|
||||
.arguments("<path:string> <version:string>")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(showVersion as any);
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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";
|
||||
|
||||
async function list(opts: GlobalOptions & { json?: boolean }) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const groups = await wmill.listGroups({
|
||||
workspace: workspace.workspaceId,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(groups));
|
||||
} else {
|
||||
if (groups.length === 0) {
|
||||
log.info("No groups found.");
|
||||
return;
|
||||
}
|
||||
new Table()
|
||||
.header(["Name", "Summary", "Members"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
groups.map((g) => [
|
||||
g.name,
|
||||
g.summary ?? "-",
|
||||
String(g.members?.length ?? 0),
|
||||
])
|
||||
)
|
||||
.render();
|
||||
}
|
||||
}
|
||||
|
||||
async function get(
|
||||
opts: GlobalOptions & { json?: boolean },
|
||||
name: string
|
||||
) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const group = await wmill.getGroup({
|
||||
workspace: workspace.workspaceId,
|
||||
name,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(group));
|
||||
} else {
|
||||
console.log(colors.bold("Name:") + " " + group.name);
|
||||
console.log(colors.bold("Summary:") + " " + (group.summary ?? "-"));
|
||||
console.log(
|
||||
colors.bold("Members:") +
|
||||
" " +
|
||||
(group.members && group.members.length > 0
|
||||
? group.members.join(", ")
|
||||
: "(none)")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function create(
|
||||
opts: GlobalOptions & { summary?: string },
|
||||
name: string
|
||||
) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
await wmill.createGroup({
|
||||
workspace: workspace.workspaceId,
|
||||
requestBody: {
|
||||
name,
|
||||
summary: opts.summary,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(colors.green(`Group '${name}' created.`));
|
||||
}
|
||||
|
||||
async function deleteGroup(opts: GlobalOptions, name: string) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
await wmill.deleteGroup({
|
||||
workspace: workspace.workspaceId,
|
||||
name,
|
||||
});
|
||||
|
||||
log.info(colors.green(`Group '${name}' deleted.`));
|
||||
}
|
||||
|
||||
async function addUser(opts: GlobalOptions, name: string, username: string) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
await wmill.addUserToGroup({
|
||||
workspace: workspace.workspaceId,
|
||||
name,
|
||||
requestBody: { username },
|
||||
});
|
||||
|
||||
log.info(colors.green(`User '${username}' added to group '${name}'.`));
|
||||
}
|
||||
|
||||
async function removeUser(opts: GlobalOptions, name: string, username: string) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
await wmill.removeUserToGroup({
|
||||
workspace: workspace.workspaceId,
|
||||
name,
|
||||
requestBody: { username },
|
||||
});
|
||||
|
||||
log.info(colors.green(`User '${username}' removed from group '${name}'.`));
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.description("Manage workspace groups")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(list as any)
|
||||
.command("list", "List all groups in the workspace")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(list as any)
|
||||
.command("get", "Get group details and members")
|
||||
.arguments("<name:string>")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(get as any)
|
||||
.command("create", "Create a new group")
|
||||
.arguments("<name:string>")
|
||||
.option("--summary <summary:string>", "Group summary/description")
|
||||
.action(create as any)
|
||||
.command("delete", "Delete a group")
|
||||
.arguments("<name:string>")
|
||||
.action(deleteGroup as any)
|
||||
.command("add-user", "Add a user to a group")
|
||||
.arguments("<name:string> <username:string>")
|
||||
.action(addUser as any)
|
||||
.command("remove-user", "Remove a user from a group")
|
||||
.arguments("<name:string> <username:string>")
|
||||
.action(removeUser as any);
|
||||
|
||||
export default command;
|
||||
@@ -0,0 +1,234 @@
|
||||
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 { formatTimestamp } from "../../utils/utils.ts";
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m${remainingSeconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
function getJobStatus(job: any): string {
|
||||
if (job.type === "QueuedJob") {
|
||||
if (job.canceled) return colors.red("canceled");
|
||||
if (job.running) return colors.blue("running");
|
||||
return colors.yellow("queued");
|
||||
}
|
||||
// CompletedJob
|
||||
if (job.canceled) return colors.red("canceled");
|
||||
if (job.success) return colors.green("success");
|
||||
return colors.red("failure");
|
||||
}
|
||||
|
||||
function getJobStatusPlain(job: any): string {
|
||||
if (job.type === "QueuedJob") {
|
||||
if (job.canceled) return "canceled";
|
||||
if (job.running) return "running";
|
||||
return "queued";
|
||||
}
|
||||
if (job.canceled) return "canceled";
|
||||
if (job.success) return "success";
|
||||
return "failure";
|
||||
}
|
||||
|
||||
async function list(
|
||||
opts: GlobalOptions & {
|
||||
json?: boolean;
|
||||
scriptPath?: string;
|
||||
createdBy?: string;
|
||||
running?: boolean;
|
||||
success?: boolean;
|
||||
failed?: boolean;
|
||||
limit?: number;
|
||||
jobKinds?: string;
|
||||
label?: string;
|
||||
all?: boolean;
|
||||
}
|
||||
) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
// --failed is a convenience alias for --success false
|
||||
let successFilter = opts.success;
|
||||
if (opts.failed) successFilter = false;
|
||||
|
||||
const jobs = await wmill.listJobs({
|
||||
workspace: workspace.workspaceId,
|
||||
scriptPathExact: opts.scriptPath,
|
||||
createdBy: opts.createdBy,
|
||||
running: opts.running,
|
||||
success: successFilter,
|
||||
perPage: Math.min(opts.limit ?? 30, 100),
|
||||
jobKinds: opts.jobKinds ?? "script,flow,singlestepflow",
|
||||
label: opts.label,
|
||||
hasNullParent: opts.all ? undefined : true,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(jobs));
|
||||
} else {
|
||||
if (jobs.length === 0) {
|
||||
log.info("No jobs found.");
|
||||
return;
|
||||
}
|
||||
new Table()
|
||||
.header(["ID", "Status", "Script/Flow", "Created By", "Duration", "Created At"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
jobs.map((j: any) => [
|
||||
j.id.substring(0, 8),
|
||||
getJobStatus(j),
|
||||
j.script_path ?? j.raw_code?.substring(0, 30) ?? "-",
|
||||
j.created_by ?? j.email ?? "-",
|
||||
j.duration_ms != null ? formatDuration(j.duration_ms) : (j.running ? "running" : "-"),
|
||||
j.created_at ? formatTimestamp(j.created_at) : "-",
|
||||
])
|
||||
)
|
||||
.render();
|
||||
log.info(`\nShowing ${jobs.length} job(s). Use --limit to show more.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function get(
|
||||
opts: GlobalOptions & { json?: boolean },
|
||||
id: string
|
||||
) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const job = await wmill.getJob({
|
||||
workspace: workspace.workspaceId,
|
||||
id,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(job));
|
||||
} else {
|
||||
const j = job as any;
|
||||
console.log(colors.bold("ID:") + " " + j.id);
|
||||
console.log(colors.bold("Status:") + " " + getJobStatusPlain(j));
|
||||
console.log(colors.bold("Kind:") + " " + j.job_kind);
|
||||
console.log(colors.bold("Script Path:") + " " + (j.script_path ?? "-"));
|
||||
console.log(colors.bold("Created By:") + " " + (j.created_by ?? "-"));
|
||||
console.log(colors.bold("Created At:") + " " + (j.created_at ? formatTimestamp(j.created_at) : "-"));
|
||||
if (j.started_at) {
|
||||
console.log(colors.bold("Started At:") + " " + formatTimestamp(j.started_at));
|
||||
}
|
||||
if (j.duration_ms != null) {
|
||||
console.log(colors.bold("Duration:") + " " + formatDuration(j.duration_ms));
|
||||
}
|
||||
if (j.schedule_path) {
|
||||
console.log(colors.bold("Schedule:") + " " + j.schedule_path);
|
||||
}
|
||||
if (j.result !== undefined) {
|
||||
console.log(colors.bold("Result:"));
|
||||
console.log(JSON.stringify(j.result, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function result(
|
||||
opts: GlobalOptions,
|
||||
id: string
|
||||
) {
|
||||
log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const jobResult = await wmill.getCompletedJobResult({
|
||||
workspace: workspace.workspaceId,
|
||||
id,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(jobResult));
|
||||
}
|
||||
|
||||
async function logs(
|
||||
opts: GlobalOptions,
|
||||
id: string
|
||||
) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const jobLogs = await wmill.getJobLogs({
|
||||
workspace: workspace.workspaceId,
|
||||
id,
|
||||
});
|
||||
|
||||
console.log(jobLogs);
|
||||
}
|
||||
|
||||
async function cancel(
|
||||
opts: GlobalOptions & { reason?: string },
|
||||
id: string
|
||||
) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
await wmill.cancelQueuedJob({
|
||||
workspace: workspace.workspaceId,
|
||||
id,
|
||||
requestBody: {
|
||||
reason: opts.reason ?? "Canceled via CLI",
|
||||
},
|
||||
});
|
||||
|
||||
log.info(colors.green(`Job ${id} canceled.`));
|
||||
}
|
||||
|
||||
// Shared list options to avoid repetition between default action and list subcommand
|
||||
const listOptions = (cmd: Command) =>
|
||||
cmd
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.option("--script-path <scriptPath:string>", "Filter by exact script/flow path")
|
||||
.option("--created-by <createdBy:string>", "Filter by creator username")
|
||||
.option("--running", "Show only running jobs")
|
||||
.option("--failed", "Show only failed jobs")
|
||||
.option("--success <success:boolean>", "Filter by success status (true/false)")
|
||||
.option("--limit <limit:number>", "Number of jobs to return (default 30, max 100)")
|
||||
.option("--job-kinds <jobKinds:string>", "Filter by job kinds (default: script,flow,singlestepflow)")
|
||||
.option("--label <label:string>", "Filter by job label")
|
||||
.option("--all", "Include sub-jobs (flow steps). By default only top-level jobs are shown");
|
||||
|
||||
const command = listOptions(new Command()
|
||||
.description("Manage jobs (list, inspect, cancel)"))
|
||||
.action(list as any)
|
||||
.command("list", listOptions(new Command().description("List recent jobs")))
|
||||
.action(list as any)
|
||||
.command("get", "Get job details and result")
|
||||
.arguments("<id:string>")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(get as any)
|
||||
.command("result", "Get the result of a completed job (machine-friendly)")
|
||||
.arguments("<id:string>")
|
||||
.action(result as any)
|
||||
.command("logs", "Get job logs")
|
||||
.arguments("<id:string>")
|
||||
.action(logs as any)
|
||||
.command("cancel", "Cancel a running or queued job")
|
||||
.arguments("<id:string>")
|
||||
.option("--reason <reason:string>", "Reason for cancellation")
|
||||
.action(cancel as any);
|
||||
|
||||
export default command;
|
||||
@@ -8,6 +8,7 @@ import * as log from "../../core/log.ts";
|
||||
import { sep as SEP } from "node:path";
|
||||
import { requireLogin } from "../../core/auth.ts";
|
||||
import { resolveWorkspace, validatePath } from "../../core/context.ts";
|
||||
import { mergeConfigWithConfigFile } from "../../core/conf.ts";
|
||||
import * as wmill from "../../../gen/services.gen.ts";
|
||||
|
||||
import {
|
||||
@@ -163,6 +164,34 @@ export async function pushSchedule(
|
||||
}
|
||||
}
|
||||
|
||||
async function enable(opts: GlobalOptions, path: string) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
await wmill.setScheduleEnabled({
|
||||
workspace: workspace.workspaceId,
|
||||
path,
|
||||
requestBody: { enabled: true },
|
||||
});
|
||||
|
||||
log.info(colors.green(`Schedule ${path} enabled.`));
|
||||
}
|
||||
|
||||
async function disable(opts: GlobalOptions, path: string) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
await wmill.setScheduleEnabled({
|
||||
workspace: workspace.workspaceId,
|
||||
path,
|
||||
requestBody: { enabled: false },
|
||||
});
|
||||
|
||||
log.info(colors.yellow(`Schedule ${path} disabled.`));
|
||||
}
|
||||
|
||||
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
@@ -206,6 +235,12 @@ const command = new Command()
|
||||
"push a local schedule spec. This overrides any remote versions."
|
||||
)
|
||||
.arguments("<file_path:string> <remote_path:string>")
|
||||
.action(push as any);
|
||||
.action(push as any)
|
||||
.command("enable", "Enable a schedule")
|
||||
.arguments("<path:string>")
|
||||
.action(enable as any)
|
||||
.command("disable", "Disable a schedule")
|
||||
.arguments("<path:string>")
|
||||
.action(disable as any);
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -1501,6 +1501,42 @@ async function preview(
|
||||
}
|
||||
}
|
||||
|
||||
async function history(
|
||||
opts: GlobalOptions & { json?: boolean },
|
||||
scriptPath: string
|
||||
) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const versions = await wmill.getScriptHistoryByPath({
|
||||
workspace: workspace.workspaceId,
|
||||
path: scriptPath,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(versions));
|
||||
} else {
|
||||
if (versions.length === 0) {
|
||||
log.info("No version history found for " + scriptPath);
|
||||
return;
|
||||
}
|
||||
new Table()
|
||||
.header(["#", "Hash", "Deployment Message"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
versions.map((v, i) => [
|
||||
String(versions.length - i),
|
||||
v.script_hash,
|
||||
v.deployment_msg ?? "-",
|
||||
])
|
||||
)
|
||||
.render();
|
||||
}
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.description("script related commands")
|
||||
.option("--show-archived", "Enable archived scripts in output")
|
||||
@@ -1575,6 +1611,13 @@ const command = new Command()
|
||||
"-e --excludes <patterns:file[]>",
|
||||
"Comma separated patterns to specify which file to NOT take into account."
|
||||
)
|
||||
.action(generateMetadata as any);
|
||||
.action(generateMetadata as any)
|
||||
.command(
|
||||
"history",
|
||||
"show version history for a script"
|
||||
)
|
||||
.arguments("<path:string>")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(history as any);
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { GlobalOptions } from "../../types.ts";
|
||||
import { requireLogin } from "../../core/auth.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 { formatTimestamp } from "../../utils/utils.ts";
|
||||
|
||||
async function list(opts: GlobalOptions & { json?: boolean }) {
|
||||
if (opts.json) log.setSilent(true);
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const tokens = await wmill.listTokens({
|
||||
excludeEphemeral: true,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(tokens));
|
||||
} else {
|
||||
if (tokens.length === 0) {
|
||||
log.info("No tokens found.");
|
||||
return;
|
||||
}
|
||||
new Table()
|
||||
.header(["Prefix", "Label", "Created At", "Last Used", "Expiration"])
|
||||
.padding(2)
|
||||
.border(true)
|
||||
.body(
|
||||
tokens.map((t) => [
|
||||
t.token_prefix,
|
||||
t.label ?? "-",
|
||||
formatTimestamp(t.created_at),
|
||||
formatTimestamp(t.last_used_at),
|
||||
t.expiration ? formatTimestamp(t.expiration) : "never",
|
||||
])
|
||||
)
|
||||
.render();
|
||||
}
|
||||
}
|
||||
|
||||
async function create(
|
||||
opts: GlobalOptions & {
|
||||
label?: string;
|
||||
expiration?: string;
|
||||
}
|
||||
) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
const token = await wmill.createToken({
|
||||
requestBody: {
|
||||
label: opts.label,
|
||||
expiration: opts.expiration,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(token);
|
||||
}
|
||||
|
||||
async function deleteToken(opts: GlobalOptions, tokenPrefix: string) {
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
await requireLogin(opts);
|
||||
|
||||
await wmill.deleteToken({ tokenPrefix });
|
||||
|
||||
log.info(colors.green(`Token with prefix '${tokenPrefix}' deleted.`));
|
||||
}
|
||||
|
||||
const command = new Command()
|
||||
.description("Manage API tokens")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(list as any)
|
||||
.command("list", "List API tokens")
|
||||
.option("--json", "Output as JSON (for piping to jq)")
|
||||
.action(list as any)
|
||||
.command("create", "Create a new API token")
|
||||
.option("--label <label:string>", "Token label")
|
||||
.option("--expiration <expiration:string>", "Token expiration (ISO 8601 timestamp)")
|
||||
.action(create as any)
|
||||
.command("delete", "Delete a token by its prefix")
|
||||
.arguments("<token_prefix:string>")
|
||||
.action(deleteToken as any);
|
||||
|
||||
export default command;
|
||||
@@ -4999,6 +4999,16 @@ app related commands
|
||||
- \`--dry-run\` - Perform a dry run without making changes
|
||||
- \`--default-ts <runtime:string>\` - Default TypeScript runtime (bun or deno)
|
||||
|
||||
### audit
|
||||
|
||||
View audit logs (requires admin)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`audit list\` - List audit log entries
|
||||
- \`audit get <id:string>\` - Get a specific audit log entry
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
### config
|
||||
|
||||
Show all available wmill.yaml configuration options
|
||||
@@ -5066,6 +5076,10 @@ flow related commands
|
||||
- \`flow bootstrap <flow_path:string>\` - create a new empty flow (alias for new
|
||||
- \`--summary <summary:string>\` - flow summary
|
||||
- \`--description <description:string>\` - flow description
|
||||
- \`flow history <path:string>\` - Show version history for a flow
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`flow show-version <path:string> <version:string>\` - Show a specific version of a flow
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
### folder
|
||||
|
||||
@@ -5128,6 +5142,25 @@ Manage git-sync settings between local wmill.yaml and Windmill backend
|
||||
- \`--yes\` - Skip interactive prompts and use default behavior
|
||||
- \`--promotion <branch:string>\` - Use promotionOverrides from the specified branch instead of regular overrides
|
||||
|
||||
### group
|
||||
|
||||
Manage workspace groups
|
||||
|
||||
**Options:**
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`group list\` - List all groups in the workspace
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`group get <name:string>\` - Get group details and members
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`group create <name:string>\` - Create a new group
|
||||
- \`--summary <summary:string>\` - Group summary/description
|
||||
- \`group delete <name:string>\` - Delete a group
|
||||
- \`group add-user <name:string> <username:string>\` - Add a user to a group
|
||||
- \`group remove-user <name:string> <username:string>\` - Remove a user from a group
|
||||
|
||||
### hub
|
||||
|
||||
Hub related commands. EXPERIMENTAL. INTERNAL USE ONLY.
|
||||
@@ -5186,6 +5219,20 @@ sync local with a remote instance or the opposite (push or pull)
|
||||
- \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting
|
||||
- \`--instance <instance:string>\` - Name of the instance, override the active instance
|
||||
|
||||
### job
|
||||
|
||||
Manage jobs (list, inspect, cancel)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`job list\` - List recent jobs
|
||||
- \`job get <id:string>\` - Get job details and result
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`job result <id:string>\` - Get the result of a completed job (machine-friendly
|
||||
- \`job logs <id:string>\` - Get job logs
|
||||
- \`job cancel <id:string>\` - Cancel a running or queued job
|
||||
- \`--reason <reason:string>\` - Reason for cancellation
|
||||
|
||||
### jobs
|
||||
|
||||
Pull completed and queued jobs from workspace
|
||||
@@ -5272,6 +5319,8 @@ schedule related commands
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`schedule new <path:string>\` - create a new schedule locally
|
||||
- \`schedule push <file_path:string> <remote_path:string>\` - push a local schedule spec. This overrides any remote versions.
|
||||
- \`schedule enable <path:string>\` - Enable a schedule
|
||||
- \`schedule disable <path:string>\` - Disable a schedule
|
||||
|
||||
### script
|
||||
|
||||
@@ -5309,6 +5358,8 @@ script related commands
|
||||
- \`--schema-only\` - re-generate only script schema
|
||||
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
|
||||
- \`-e --excludes <patterns:file[]>\` - Comma separated patterns to specify which file to NOT take into account.
|
||||
- \`script history <path:string>\` - show version history for a script
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
### sync
|
||||
|
||||
@@ -5381,6 +5432,22 @@ sync local with a remote workspaces or the opposite (push or pull)
|
||||
- \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks
|
||||
- \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing
|
||||
|
||||
### token
|
||||
|
||||
Manage API tokens
|
||||
|
||||
**Options:**
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`token list\` - List API tokens
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`token create\` - Create a new API token
|
||||
- \`--label <label:string>\` - Token label
|
||||
- \`--expiration <expiration:string>\` - Token expiration (ISO 8601 timestamp)
|
||||
- \`token delete <token_prefix:string>\` - Delete a token by its prefix
|
||||
|
||||
### trigger
|
||||
|
||||
trigger related commands
|
||||
|
||||
@@ -39,6 +39,10 @@ import queues from "./commands/queues/queues.ts";
|
||||
import dependencies from "./commands/dependencies/dependencies.ts";
|
||||
import init from "./commands/init/init.ts";
|
||||
import jobs from "./commands/jobs/jobs.ts";
|
||||
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 generateMetadata from "./commands/generate-metadata/generate-metadata.ts";
|
||||
import docs from "./commands/docs/docs.ts";
|
||||
import config from "./commands/config/config.ts";
|
||||
@@ -68,6 +72,10 @@ export {
|
||||
pull,
|
||||
push,
|
||||
workspaceAdd,
|
||||
job,
|
||||
group,
|
||||
audit,
|
||||
token,
|
||||
};
|
||||
|
||||
export const VERSION = "1.667.0";
|
||||
@@ -132,6 +140,10 @@ const command = new Command()
|
||||
.command("queues", queues)
|
||||
.command("dependencies", dependencies)
|
||||
.command("jobs", jobs)
|
||||
.command("job", job)
|
||||
.command("group", group)
|
||||
.command("audit", audit)
|
||||
.command("token", token)
|
||||
.command("generate-metadata", generateMetadata)
|
||||
.command("docs", docs)
|
||||
.command("config", config)
|
||||
|
||||
@@ -287,3 +287,7 @@ export function toCamel(s: string) {
|
||||
export function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts: string): string {
|
||||
return new Date(ts).toISOString().replace("T", " ").substring(0, 19);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Integration tests for `wmill audit` and `wmill token` commands.
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { withTestBackend } from "./test_backend.ts";
|
||||
import { setupWorkspaceProfile, createRemoteScript } from "./new_commands_helpers.ts";
|
||||
|
||||
// =============================================================================
|
||||
// audit commands
|
||||
// =============================================================================
|
||||
|
||||
describe("audit command", () => {
|
||||
test("audit list returns valid JSON", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
await createRemoteScript(backend, `f/test/audit_test_${Date.now()}`);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["audit", "list", "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("audit list with filters", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
await createRemoteScript(backend, `f/test/audit_filter_${Date.now()}`);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["audit", "list", "--json", "--operation", "scripts", "--limit", "5"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
if (parsed.length > 0) {
|
||||
expect(parsed[0].operation).toMatch(/^scripts/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("audit list shows table or empty message", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
await createRemoteScript(backend, `f/test/audit_table_${Date.now()}`);
|
||||
|
||||
const result = await backend.runCLICommand(["audit", "list"], tempDir);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const output = result.stdout;
|
||||
const hasTable = output.includes("ID") && output.includes("Operation");
|
||||
const hasEmpty = output.includes("No audit logs found");
|
||||
expect(hasTable || hasEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("audit get returns specific entry", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
await createRemoteScript(backend, `f/test/audit_get_${Date.now()}`);
|
||||
|
||||
const listResult = await backend.runCLICommand(
|
||||
["audit", "list", "--json", "--limit", "1"],
|
||||
tempDir
|
||||
);
|
||||
expect(listResult.code).toEqual(0);
|
||||
const logs = JSON.parse(listResult.stdout);
|
||||
if (logs.length === 0) return;
|
||||
|
||||
const auditId = String(logs[0].id);
|
||||
const getResult = await backend.runCLICommand(
|
||||
["audit", "get", auditId, "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(getResult.code).toEqual(0);
|
||||
const parsed = JSON.parse(getResult.stdout);
|
||||
expect(parsed.id).toBe(logs[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
test("audit --help shows all subcommands", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
const result = await backend.runCLICommand(["audit", "--help"], tempDir);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("list");
|
||||
expect(output).toContain("get");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// token commands
|
||||
// =============================================================================
|
||||
|
||||
describe("token command", () => {
|
||||
test("token list returns valid JSON", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["token", "list", "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("token list shows table output", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const result = await backend.runCLICommand(["token", "list"], tempDir);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout).toContain("Prefix");
|
||||
expect(result.stdout).toContain("Label");
|
||||
});
|
||||
});
|
||||
|
||||
test("token create + delete lifecycle", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
// Create
|
||||
const createResult = await backend.runCLICommand(
|
||||
["token", "create", "--label", "cli-test-token"],
|
||||
tempDir
|
||||
);
|
||||
expect(createResult.code).toEqual(0);
|
||||
const newToken = createResult.stdout.trim();
|
||||
expect(newToken.length).toBeGreaterThan(10);
|
||||
|
||||
// List and find it
|
||||
const listResult = await backend.runCLICommand(
|
||||
["token", "list", "--json"],
|
||||
tempDir
|
||||
);
|
||||
expect(listResult.code).toEqual(0);
|
||||
const tokens = JSON.parse(listResult.stdout);
|
||||
const found = tokens.find((t: any) => t.label === "cli-test-token");
|
||||
expect(found).toBeDefined();
|
||||
|
||||
// Delete
|
||||
const deleteResult = await backend.runCLICommand(
|
||||
["token", "delete", found.token_prefix],
|
||||
tempDir
|
||||
);
|
||||
expect(deleteResult.code).toEqual(0);
|
||||
expect(deleteResult.stdout).toContain("deleted");
|
||||
});
|
||||
});
|
||||
|
||||
test("default action (wmill token) lists tokens", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const result = await backend.runCLICommand(["token", "--json"], tempDir);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("token --help shows all subcommands", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
const result = await backend.runCLICommand(["token", "--help"], tempDir);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("list");
|
||||
expect(output).toContain("create");
|
||||
expect(output).toContain("delete");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Integration tests for `wmill group` commands:
|
||||
* list, get, create, delete, add-user, remove-user
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { withTestBackend } from "./test_backend.ts";
|
||||
import { setupWorkspaceProfile } from "./new_commands_helpers.ts";
|
||||
|
||||
describe("group command", () => {
|
||||
test("group list returns valid JSON", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["group", "list", "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.some((g: any) => g.name === "all")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("group list shows table output", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["group", "list"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout).toContain("Name");
|
||||
expect(result.stdout).toContain("Summary");
|
||||
expect(result.stdout).toContain("Members");
|
||||
});
|
||||
});
|
||||
|
||||
test("group create + get + delete lifecycle", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const groupName = `cli_test_${Date.now()}`;
|
||||
|
||||
// Create
|
||||
const createResult = await backend.runCLICommand(
|
||||
["group", "create", groupName, "--summary", "CLI test group"],
|
||||
tempDir
|
||||
);
|
||||
expect(createResult.code).toEqual(0);
|
||||
expect(createResult.stdout).toContain("created");
|
||||
|
||||
// Get
|
||||
const getResult = await backend.runCLICommand(
|
||||
["group", "get", groupName],
|
||||
tempDir
|
||||
);
|
||||
expect(getResult.code).toEqual(0);
|
||||
expect(getResult.stdout).toContain(groupName);
|
||||
expect(getResult.stdout).toContain("CLI test group");
|
||||
|
||||
// Get --json
|
||||
const getJsonResult = await backend.runCLICommand(
|
||||
["group", "get", groupName, "--json"],
|
||||
tempDir
|
||||
);
|
||||
expect(getJsonResult.code).toEqual(0);
|
||||
const parsed = JSON.parse(getJsonResult.stdout);
|
||||
expect(parsed.name).toBe(groupName);
|
||||
|
||||
// Delete
|
||||
const deleteResult = await backend.runCLICommand(
|
||||
["group", "delete", groupName],
|
||||
tempDir
|
||||
);
|
||||
expect(deleteResult.code).toEqual(0);
|
||||
expect(deleteResult.stdout).toContain("deleted");
|
||||
});
|
||||
});
|
||||
|
||||
test("group add-user and remove-user", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const groupName = `cli_member_test_${Date.now()}`;
|
||||
|
||||
await backend.runCLICommand(["group", "create", groupName], tempDir);
|
||||
|
||||
// Add user
|
||||
const addResult = await backend.runCLICommand(
|
||||
["group", "add-user", groupName, "admin@windmill.dev"],
|
||||
tempDir
|
||||
);
|
||||
expect(addResult.code).toEqual(0);
|
||||
expect(addResult.stdout).toContain("added");
|
||||
|
||||
// Remove user
|
||||
const removeResult = await backend.runCLICommand(
|
||||
["group", "remove-user", groupName, "admin@windmill.dev"],
|
||||
tempDir
|
||||
);
|
||||
expect(removeResult.code).toEqual(0);
|
||||
expect(removeResult.stdout).toContain("removed");
|
||||
|
||||
// Cleanup
|
||||
await backend.runCLICommand(["group", "delete", groupName], tempDir);
|
||||
});
|
||||
});
|
||||
|
||||
test("default action (wmill group) lists groups", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const result = await backend.runCLICommand(["group", "--json"], tempDir);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("group --help shows all subcommands", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
const result = await backend.runCLICommand(["group", "--help"], tempDir);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("list");
|
||||
expect(output).toContain("get");
|
||||
expect(output).toContain("create");
|
||||
expect(output).toContain("delete");
|
||||
expect(output).toContain("add-user");
|
||||
expect(output).toContain("remove-user");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Integration tests for `wmill job` commands:
|
||||
* list, get, result, logs, cancel
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { withTestBackend } from "./test_backend.ts";
|
||||
import {
|
||||
setupWorkspaceProfile,
|
||||
createRemoteScript,
|
||||
runRemoteScript,
|
||||
waitForJob,
|
||||
} from "./new_commands_helpers.ts";
|
||||
|
||||
describe("job command", () => {
|
||||
test("job list returns valid JSON", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/job_test_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
const jobId = await runRemoteScript(backend, scriptPath);
|
||||
await waitForJob(backend, jobId);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["job", "list", "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.some((j: any) => j.id === jobId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("job list shows table output", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/job_table_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
const jobId = await runRemoteScript(backend, scriptPath);
|
||||
await waitForJob(backend, jobId);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["job", "list"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout).toContain("ID");
|
||||
expect(result.stdout).toContain("Status");
|
||||
expect(result.stdout).toContain(jobId.substring(0, 8));
|
||||
});
|
||||
});
|
||||
|
||||
test("job list --script-path filters correctly", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/filter_test_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
const jobId = await runRemoteScript(backend, scriptPath);
|
||||
await waitForJob(backend, jobId);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["job", "list", "--json", "--script-path", scriptPath],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(parsed.every((j: any) => j.script_path === scriptPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("job get returns job details", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/job_get_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
const jobId = await runRemoteScript(backend, scriptPath);
|
||||
await waitForJob(backend, jobId);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["job", "get", jobId],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout).toContain("ID:");
|
||||
expect(result.stdout).toContain(jobId);
|
||||
expect(result.stdout).toContain("Status:");
|
||||
expect(result.stdout).toContain("success");
|
||||
});
|
||||
});
|
||||
|
||||
test("job get --json returns valid JSON", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/job_get_json_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
const jobId = await runRemoteScript(backend, scriptPath);
|
||||
await waitForJob(backend, jobId);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["job", "get", jobId, "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(parsed.id).toBe(jobId);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("job result returns job result as JSON", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/job_result_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
const jobId = await runRemoteScript(backend, scriptPath);
|
||||
await waitForJob(backend, jobId);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["job", "result", jobId],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
// Result may be in stdout or combined output
|
||||
const output = result.stdout.trim();
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
test("job logs returns job logs", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/job_logs_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
const jobId = await runRemoteScript(backend, scriptPath);
|
||||
await waitForJob(backend, jobId);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["job", "logs", jobId],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("default action (wmill job) lists jobs", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["job", "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("job --help shows all subcommands", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
const result = await backend.runCLICommand(["job", "--help"], tempDir);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("list");
|
||||
expect(output).toContain("get");
|
||||
expect(output).toContain("result");
|
||||
expect(output).toContain("logs");
|
||||
expect(output).toContain("cancel");
|
||||
expect(output).toContain("--failed");
|
||||
expect(output).toContain("--running");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Shared helpers for new CLI command tests.
|
||||
*/
|
||||
|
||||
import { expect } from "bun:test";
|
||||
import { type TestBackend } from "./test_backend.ts";
|
||||
import { addWorkspace } from "../workspace.ts";
|
||||
|
||||
export async function setupWorkspaceProfile(backend: TestBackend): Promise<void> {
|
||||
await addWorkspace(
|
||||
{
|
||||
remote: backend.baseUrl,
|
||||
workspaceId: backend.workspace,
|
||||
name: "localhost_test",
|
||||
token: backend.token!,
|
||||
},
|
||||
{ force: true, configDir: backend.testConfigDir }
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureFolder(backend: TestBackend, name: string): Promise<void> {
|
||||
const resp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/folders/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}
|
||||
);
|
||||
await resp.text();
|
||||
}
|
||||
|
||||
export async function createRemoteScript(
|
||||
backend: TestBackend,
|
||||
scriptPath: string,
|
||||
content: string = 'export async function main() { return "hello"; }'
|
||||
): Promise<void> {
|
||||
const parts = scriptPath.split("/");
|
||||
if (parts[0] === "f" && parts.length > 2) {
|
||||
await ensureFolder(backend, parts[1]);
|
||||
}
|
||||
|
||||
const resp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/scripts/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: scriptPath,
|
||||
content,
|
||||
language: "bun",
|
||||
summary: "Test script",
|
||||
schema: {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(resp.status).toBeLessThan(300);
|
||||
await resp.text();
|
||||
}
|
||||
|
||||
export async function runRemoteScript(
|
||||
backend: TestBackend,
|
||||
scriptPath: string,
|
||||
retries: number = 10
|
||||
): Promise<string> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const resp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/jobs/run/p/${scriptPath}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
if (resp.status < 300) {
|
||||
return (await resp.text()).replace(/"/g, "");
|
||||
}
|
||||
await resp.text();
|
||||
if (i < retries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to run script ${scriptPath} after ${retries} retries`);
|
||||
}
|
||||
|
||||
export async function waitForJob(
|
||||
backend: TestBackend,
|
||||
jobId: string,
|
||||
timeoutMs: number = 15000
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const resp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/jobs_u/completed/get/${jobId}`
|
||||
);
|
||||
if (resp.ok) return;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Job ${jobId} did not complete within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
export async function createRemoteFlow(
|
||||
backend: TestBackend,
|
||||
flowPath: string
|
||||
): Promise<void> {
|
||||
const parts = flowPath.split("/");
|
||||
if (parts[0] === "f" && parts.length > 2) {
|
||||
await ensureFolder(backend, parts[1]);
|
||||
}
|
||||
|
||||
const resp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/flows/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: flowPath,
|
||||
summary: "Test flow",
|
||||
description: "A test flow",
|
||||
value: {
|
||||
modules: [
|
||||
{
|
||||
id: "a",
|
||||
value: {
|
||||
type: "rawscript",
|
||||
content: 'export async function main() { return "flow done"; }',
|
||||
language: "bun",
|
||||
input_transforms: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
schema: {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(resp.status).toBeLessThan(300);
|
||||
await resp.text();
|
||||
}
|
||||
|
||||
export async function createRemoteSchedule(
|
||||
backend: TestBackend,
|
||||
schedulePath: string,
|
||||
scriptPath: string
|
||||
): Promise<void> {
|
||||
const parts = schedulePath.split("/");
|
||||
if (parts[0] === "f" && parts.length > 2) {
|
||||
await ensureFolder(backend, parts[1]);
|
||||
}
|
||||
|
||||
const resp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/schedules/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: schedulePath,
|
||||
schedule: "0 0 */6 * * *",
|
||||
timezone: "Etc/UTC",
|
||||
script_path: scriptPath,
|
||||
is_flow: false,
|
||||
args: {},
|
||||
enabled: false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(resp.status).toBeLessThan(300);
|
||||
await resp.text();
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Integration tests for:
|
||||
* - `wmill schedule enable/disable`
|
||||
* - `wmill script history`
|
||||
* - `wmill flow history/show-version`
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { withTestBackend } from "./test_backend.ts";
|
||||
import {
|
||||
setupWorkspaceProfile,
|
||||
createRemoteScript,
|
||||
createRemoteFlow,
|
||||
createRemoteSchedule,
|
||||
} from "./new_commands_helpers.ts";
|
||||
|
||||
// =============================================================================
|
||||
// schedule enable/disable commands
|
||||
// =============================================================================
|
||||
|
||||
describe("schedule enable/disable", () => {
|
||||
test("schedule enable and disable toggle schedule state", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/sched_script_${uniqueId}`;
|
||||
const schedulePath = `f/test/sched_${uniqueId}`;
|
||||
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
await createRemoteSchedule(backend, schedulePath, scriptPath);
|
||||
|
||||
// Enable
|
||||
const enableResult = await backend.runCLICommand(
|
||||
["schedule", "enable", schedulePath],
|
||||
tempDir
|
||||
);
|
||||
expect(enableResult.code).toEqual(0);
|
||||
expect(enableResult.stdout).toContain("enabled");
|
||||
|
||||
// Verify enabled via get
|
||||
const getResult1 = await backend.runCLICommand(
|
||||
["schedule", "get", schedulePath, "--json"],
|
||||
tempDir
|
||||
);
|
||||
expect(getResult1.code).toEqual(0);
|
||||
const schedule1 = JSON.parse(getResult1.stdout);
|
||||
expect(schedule1.enabled).toBe(true);
|
||||
|
||||
// Disable
|
||||
const disableResult = await backend.runCLICommand(
|
||||
["schedule", "disable", schedulePath],
|
||||
tempDir
|
||||
);
|
||||
expect(disableResult.code).toEqual(0);
|
||||
expect(disableResult.stdout).toContain("disabled");
|
||||
|
||||
// Verify disabled via get
|
||||
const getResult2 = await backend.runCLICommand(
|
||||
["schedule", "get", schedulePath, "--json"],
|
||||
tempDir
|
||||
);
|
||||
expect(getResult2.code).toEqual(0);
|
||||
const schedule2 = JSON.parse(getResult2.stdout);
|
||||
expect(schedule2.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("schedule enable/disable shows in help", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["schedule", "--help"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const output = result.stdout + result.stderr;
|
||||
expect(output).toContain("enable");
|
||||
expect(output).toContain("disable");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// script history command
|
||||
// =============================================================================
|
||||
|
||||
describe("script history", () => {
|
||||
test("script history returns version list", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/history_script_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["script", "history", scriptPath, "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(1);
|
||||
expect(parsed[0]).toHaveProperty("script_hash");
|
||||
});
|
||||
});
|
||||
|
||||
test("script history shows table output", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/history_table_${uniqueId}`;
|
||||
await createRemoteScript(backend, scriptPath);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["script", "history", scriptPath],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout).toContain("Hash");
|
||||
expect(result.stdout).toContain("Deployment Message");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// flow history and show-version commands
|
||||
// =============================================================================
|
||||
|
||||
describe("flow history", () => {
|
||||
test("flow history returns version list", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const flowPath = `f/test/history_flow_${uniqueId}`;
|
||||
await createRemoteFlow(backend, flowPath);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["flow", "history", flowPath, "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(1);
|
||||
expect(parsed[0]).toHaveProperty("id");
|
||||
expect(parsed[0]).toHaveProperty("created_at");
|
||||
});
|
||||
});
|
||||
|
||||
test("flow history shows table output", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const flowPath = `f/test/history_table_flow_${uniqueId}`;
|
||||
await createRemoteFlow(backend, flowPath);
|
||||
|
||||
const result = await backend.runCLICommand(
|
||||
["flow", "history", flowPath],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(result.code).toEqual(0);
|
||||
expect(result.stdout).toContain("Version");
|
||||
expect(result.stdout).toContain("Created At");
|
||||
});
|
||||
});
|
||||
|
||||
test("flow show-version returns specific version", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await setupWorkspaceProfile(backend);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const flowPath = `f/test/show_ver_flow_${uniqueId}`;
|
||||
await createRemoteFlow(backend, flowPath);
|
||||
|
||||
const histResult = await backend.runCLICommand(
|
||||
["flow", "history", flowPath, "--json"],
|
||||
tempDir
|
||||
);
|
||||
expect(histResult.code).toEqual(0);
|
||||
const versions = JSON.parse(histResult.stdout);
|
||||
expect(versions.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const versionId = String(versions[0].id);
|
||||
|
||||
const showResult = await backend.runCLICommand(
|
||||
["flow", "show-version", flowPath, versionId, "--json"],
|
||||
tempDir
|
||||
);
|
||||
|
||||
expect(showResult.code).toEqual(0);
|
||||
const flow = JSON.parse(showResult.stdout);
|
||||
expect(flow.path).toBe(flowPath);
|
||||
expect(flow.value).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,16 @@ app related commands
|
||||
- `--dry-run` - Perform a dry run without making changes
|
||||
- `--default-ts <runtime:string>` - Default TypeScript runtime (bun or deno)
|
||||
|
||||
### audit
|
||||
|
||||
View audit logs (requires admin)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `audit list` - List audit log entries
|
||||
- `audit get <id:string>` - Get a specific audit log entry
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
### config
|
||||
|
||||
Show all available wmill.yaml configuration options
|
||||
@@ -108,6 +118,10 @@ flow related commands
|
||||
- `flow bootstrap <flow_path:string>` - create a new empty flow (alias for new
|
||||
- `--summary <summary:string>` - flow summary
|
||||
- `--description <description:string>` - flow description
|
||||
- `flow history <path:string>` - Show version history for a flow
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `flow show-version <path:string> <version:string>` - Show a specific version of a flow
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
### folder
|
||||
|
||||
@@ -170,6 +184,25 @@ Manage git-sync settings between local wmill.yaml and Windmill backend
|
||||
- `--yes` - Skip interactive prompts and use default behavior
|
||||
- `--promotion <branch:string>` - Use promotionOverrides from the specified branch instead of regular overrides
|
||||
|
||||
### group
|
||||
|
||||
Manage workspace groups
|
||||
|
||||
**Options:**
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `group list` - List all groups in the workspace
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `group get <name:string>` - Get group details and members
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `group create <name:string>` - Create a new group
|
||||
- `--summary <summary:string>` - Group summary/description
|
||||
- `group delete <name:string>` - Delete a group
|
||||
- `group add-user <name:string> <username:string>` - Add a user to a group
|
||||
- `group remove-user <name:string> <username:string>` - Remove a user from a group
|
||||
|
||||
### hub
|
||||
|
||||
Hub related commands. EXPERIMENTAL. INTERNAL USE ONLY.
|
||||
@@ -228,6 +261,20 @@ sync local with a remote instance or the opposite (push or pull)
|
||||
- `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting
|
||||
- `--instance <instance:string>` - Name of the instance, override the active instance
|
||||
|
||||
### job
|
||||
|
||||
Manage jobs (list, inspect, cancel)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `job list` - List recent jobs
|
||||
- `job get <id:string>` - Get job details and result
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `job result <id:string>` - Get the result of a completed job (machine-friendly
|
||||
- `job logs <id:string>` - Get job logs
|
||||
- `job cancel <id:string>` - Cancel a running or queued job
|
||||
- `--reason <reason:string>` - Reason for cancellation
|
||||
|
||||
### jobs
|
||||
|
||||
Pull completed and queued jobs from workspace
|
||||
@@ -314,6 +361,8 @@ schedule related commands
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `schedule new <path:string>` - create a new schedule locally
|
||||
- `schedule push <file_path:string> <remote_path:string>` - push a local schedule spec. This overrides any remote versions.
|
||||
- `schedule enable <path:string>` - Enable a schedule
|
||||
- `schedule disable <path:string>` - Disable a schedule
|
||||
|
||||
### script
|
||||
|
||||
@@ -351,6 +400,8 @@ script related commands
|
||||
- `--schema-only` - re-generate only script schema
|
||||
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
|
||||
- `-e --excludes <patterns:file[]>` - Comma separated patterns to specify which file to NOT take into account.
|
||||
- `script history <path:string>` - show version history for a script
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
### sync
|
||||
|
||||
@@ -423,6 +474,22 @@ sync local with a remote workspaces or the opposite (push or pull)
|
||||
- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks
|
||||
- `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing
|
||||
|
||||
### token
|
||||
|
||||
Manage API tokens
|
||||
|
||||
**Options:**
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `token list` - List API tokens
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `token create` - Create a new API token
|
||||
- `--label <label:string>` - Token label
|
||||
- `--expiration <expiration:string>` - Token expiration (ISO 8601 timestamp)
|
||||
- `token delete <token_prefix:string>` - Delete a token by its prefix
|
||||
|
||||
### trigger
|
||||
|
||||
trigger related commands
|
||||
|
||||
@@ -1568,6 +1568,16 @@ app related commands
|
||||
- \`--dry-run\` - Perform a dry run without making changes
|
||||
- \`--default-ts <runtime:string>\` - Default TypeScript runtime (bun or deno)
|
||||
|
||||
### audit
|
||||
|
||||
View audit logs (requires admin)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`audit list\` - List audit log entries
|
||||
- \`audit get <id:string>\` - Get a specific audit log entry
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
### config
|
||||
|
||||
Show all available wmill.yaml configuration options
|
||||
@@ -1635,6 +1645,10 @@ flow related commands
|
||||
- \`flow bootstrap <flow_path:string>\` - create a new empty flow (alias for new
|
||||
- \`--summary <summary:string>\` - flow summary
|
||||
- \`--description <description:string>\` - flow description
|
||||
- \`flow history <path:string>\` - Show version history for a flow
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`flow show-version <path:string> <version:string>\` - Show a specific version of a flow
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
### folder
|
||||
|
||||
@@ -1697,6 +1711,25 @@ Manage git-sync settings between local wmill.yaml and Windmill backend
|
||||
- \`--yes\` - Skip interactive prompts and use default behavior
|
||||
- \`--promotion <branch:string>\` - Use promotionOverrides from the specified branch instead of regular overrides
|
||||
|
||||
### group
|
||||
|
||||
Manage workspace groups
|
||||
|
||||
**Options:**
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`group list\` - List all groups in the workspace
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`group get <name:string>\` - Get group details and members
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`group create <name:string>\` - Create a new group
|
||||
- \`--summary <summary:string>\` - Group summary/description
|
||||
- \`group delete <name:string>\` - Delete a group
|
||||
- \`group add-user <name:string> <username:string>\` - Add a user to a group
|
||||
- \`group remove-user <name:string> <username:string>\` - Remove a user from a group
|
||||
|
||||
### hub
|
||||
|
||||
Hub related commands. EXPERIMENTAL. INTERNAL USE ONLY.
|
||||
@@ -1755,6 +1788,20 @@ sync local with a remote instance or the opposite (push or pull)
|
||||
- \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting
|
||||
- \`--instance <instance:string>\` - Name of the instance, override the active instance
|
||||
|
||||
### job
|
||||
|
||||
Manage jobs (list, inspect, cancel)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`job list\` - List recent jobs
|
||||
- \`job get <id:string>\` - Get job details and result
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`job result <id:string>\` - Get the result of a completed job (machine-friendly
|
||||
- \`job logs <id:string>\` - Get job logs
|
||||
- \`job cancel <id:string>\` - Cancel a running or queued job
|
||||
- \`--reason <reason:string>\` - Reason for cancellation
|
||||
|
||||
### jobs
|
||||
|
||||
Pull completed and queued jobs from workspace
|
||||
@@ -1841,6 +1888,8 @@ schedule related commands
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`schedule new <path:string>\` - create a new schedule locally
|
||||
- \`schedule push <file_path:string> <remote_path:string>\` - push a local schedule spec. This overrides any remote versions.
|
||||
- \`schedule enable <path:string>\` - Enable a schedule
|
||||
- \`schedule disable <path:string>\` - Disable a schedule
|
||||
|
||||
### script
|
||||
|
||||
@@ -1878,6 +1927,8 @@ script related commands
|
||||
- \`--schema-only\` - re-generate only script schema
|
||||
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
|
||||
- \`-e --excludes <patterns:file[]>\` - Comma separated patterns to specify which file to NOT take into account.
|
||||
- \`script history <path:string>\` - show version history for a script
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
### sync
|
||||
|
||||
@@ -1950,6 +2001,22 @@ sync local with a remote workspaces or the opposite (push or pull)
|
||||
- \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks
|
||||
- \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing
|
||||
|
||||
### token
|
||||
|
||||
Manage API tokens
|
||||
|
||||
**Options:**
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- \`token list\` - List API tokens
|
||||
- \`--json\` - Output as JSON (for piping to jq)
|
||||
- \`token create\` - Create a new API token
|
||||
- \`--label <label:string>\` - Token label
|
||||
- \`--expiration <expiration:string>\` - Token expiration (ISO 8601 timestamp)
|
||||
- \`token delete <token_prefix:string>\` - Delete a token by its prefix
|
||||
|
||||
### trigger
|
||||
|
||||
trigger related commands
|
||||
|
||||
@@ -46,6 +46,16 @@ app related commands
|
||||
- `--dry-run` - Perform a dry run without making changes
|
||||
- `--default-ts <runtime:string>` - Default TypeScript runtime (bun or deno)
|
||||
|
||||
### audit
|
||||
|
||||
View audit logs (requires admin)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `audit list` - List audit log entries
|
||||
- `audit get <id:string>` - Get a specific audit log entry
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
### config
|
||||
|
||||
Show all available wmill.yaml configuration options
|
||||
@@ -113,6 +123,10 @@ flow related commands
|
||||
- `flow bootstrap <flow_path:string>` - create a new empty flow (alias for new
|
||||
- `--summary <summary:string>` - flow summary
|
||||
- `--description <description:string>` - flow description
|
||||
- `flow history <path:string>` - Show version history for a flow
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `flow show-version <path:string> <version:string>` - Show a specific version of a flow
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
### folder
|
||||
|
||||
@@ -175,6 +189,25 @@ Manage git-sync settings between local wmill.yaml and Windmill backend
|
||||
- `--yes` - Skip interactive prompts and use default behavior
|
||||
- `--promotion <branch:string>` - Use promotionOverrides from the specified branch instead of regular overrides
|
||||
|
||||
### group
|
||||
|
||||
Manage workspace groups
|
||||
|
||||
**Options:**
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `group list` - List all groups in the workspace
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `group get <name:string>` - Get group details and members
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `group create <name:string>` - Create a new group
|
||||
- `--summary <summary:string>` - Group summary/description
|
||||
- `group delete <name:string>` - Delete a group
|
||||
- `group add-user <name:string> <username:string>` - Add a user to a group
|
||||
- `group remove-user <name:string> <username:string>` - Remove a user from a group
|
||||
|
||||
### hub
|
||||
|
||||
Hub related commands. EXPERIMENTAL. INTERNAL USE ONLY.
|
||||
@@ -233,6 +266,20 @@ sync local with a remote instance or the opposite (push or pull)
|
||||
- `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting
|
||||
- `--instance <instance:string>` - Name of the instance, override the active instance
|
||||
|
||||
### job
|
||||
|
||||
Manage jobs (list, inspect, cancel)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `job list` - List recent jobs
|
||||
- `job get <id:string>` - Get job details and result
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `job result <id:string>` - Get the result of a completed job (machine-friendly
|
||||
- `job logs <id:string>` - Get job logs
|
||||
- `job cancel <id:string>` - Cancel a running or queued job
|
||||
- `--reason <reason:string>` - Reason for cancellation
|
||||
|
||||
### jobs
|
||||
|
||||
Pull completed and queued jobs from workspace
|
||||
@@ -319,6 +366,8 @@ schedule related commands
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `schedule new <path:string>` - create a new schedule locally
|
||||
- `schedule push <file_path:string> <remote_path:string>` - push a local schedule spec. This overrides any remote versions.
|
||||
- `schedule enable <path:string>` - Enable a schedule
|
||||
- `schedule disable <path:string>` - Disable a schedule
|
||||
|
||||
### script
|
||||
|
||||
@@ -356,6 +405,8 @@ script related commands
|
||||
- `--schema-only` - re-generate only script schema
|
||||
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
|
||||
- `-e --excludes <patterns:file[]>` - Comma separated patterns to specify which file to NOT take into account.
|
||||
- `script history <path:string>` - show version history for a script
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
### sync
|
||||
|
||||
@@ -428,6 +479,22 @@ sync local with a remote workspaces or the opposite (push or pull)
|
||||
- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks
|
||||
- `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing
|
||||
|
||||
### token
|
||||
|
||||
Manage API tokens
|
||||
|
||||
**Options:**
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `token list` - List API tokens
|
||||
- `--json` - Output as JSON (for piping to jq)
|
||||
- `token create` - Create a new API token
|
||||
- `--label <label:string>` - Token label
|
||||
- `--expiration <expiration:string>` - Token expiration (ISO 8601 timestamp)
|
||||
- `token delete <token_prefix:string>` - Delete a token by its prefix
|
||||
|
||||
### trigger
|
||||
|
||||
trigger related commands
|
||||
|
||||
Reference in New Issue
Block a user