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:
Ruben Fiszel
2026-03-28 09:18:55 +00:00
committed by GitHub
parent 820f28f879
commit d29cb234db
18 changed files with 1940 additions and 3 deletions
+113
View File
@@ -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;
+74 -1
View File
@@ -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;
+157
View File
@@ -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;
+234
View File
@@ -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;
+36 -1
View File
@@ -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;
+44 -1
View File
@@ -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;
+87
View File
@@ -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;
+67
View File
@@ -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
+12
View File
@@ -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)
+4
View File
@@ -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);
}
+189
View File
@@ -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");
});
});
});
+138
View File
@@ -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");
});
});
});
+198
View File
@@ -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");
});
});
});
+179
View File
@@ -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();
}
+207
View File
@@ -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
+67
View File
@@ -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