From d29cb234dbff07473b911e5e75e362def8a47650 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 28 Mar 2026 09:18:55 +0000 Subject: [PATCH] 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) * chore(cli): regenerate system prompts after new commands Co-Authored-By: Claude Opus 4.6 (1M context) * fix(cli): address PR review feedback Co-Authored-By: Claude Opus 4.6 (1M context) * chore(cli): regenerate system prompts after review fixes Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(cli): extract shared formatTimestamp util and remove unused resolveWorkspace in token Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/commands/audit/audit.ts | 113 +++++++++ cli/src/commands/flow/flow.ts | 75 +++++- cli/src/commands/group/group.ts | 157 ++++++++++++ cli/src/commands/job/job.ts | 234 ++++++++++++++++++ cli/src/commands/schedule/schedule.ts | 37 ++- cli/src/commands/script/script.ts | 45 +++- cli/src/commands/token/token.ts | 87 +++++++ cli/src/guidance/skills.ts | 67 +++++ cli/src/main.ts | 12 + cli/src/utils/utils.ts | 4 + cli/test/audit_token_commands.test.ts | 189 ++++++++++++++ cli/test/group_commands.test.ts | 138 +++++++++++ cli/test/job_commands.test.ts | 198 +++++++++++++++ cli/test/new_commands_helpers.ts | 179 ++++++++++++++ cli/test/schedule_history_commands.test.ts | 207 ++++++++++++++++ .../auto-generated/cli/cli-commands.md | 67 +++++ system_prompts/auto-generated/prompts.ts | 67 +++++ .../skills/cli-commands/SKILL.md | 67 +++++ 18 files changed, 1940 insertions(+), 3 deletions(-) create mode 100644 cli/src/commands/audit/audit.ts create mode 100644 cli/src/commands/group/group.ts create mode 100644 cli/src/commands/job/job.ts create mode 100644 cli/src/commands/token/token.ts create mode 100644 cli/test/audit_token_commands.test.ts create mode 100644 cli/test/group_commands.test.ts create mode 100644 cli/test/job_commands.test.ts create mode 100644 cli/test/new_commands_helpers.ts create mode 100644 cli/test/schedule_history_commands.test.ts diff --git a/cli/src/commands/audit/audit.ts b/cli/src/commands/audit/audit.ts new file mode 100644 index 0000000000..8c65413153 --- /dev/null +++ b/cli/src/commands/audit/audit.ts @@ -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 ", "Filter by username") + .option("--operation ", "Filter by operation (exact or prefix)") + .option("--action-kind ", "Filter by action kind (Create, Update, Delete, Execute)") + .option("--before ", "Filter events before this timestamp") + .option("--after ", "Filter events after this timestamp") + .option("--limit ", "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("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any); + +export default command; diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 2ca309e42b..231ac8c8b0 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -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("") .option("--summary ", "flow summary") .option("--description ", "flow description") - .action(bootstrap as any); + .action(bootstrap as any) + .command("history", "Show version history for a flow") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(history as any) + .command("show-version", "Show a specific version of a flow") + .arguments(" ") + .option("--json", "Output as JSON (for piping to jq)") + .action(showVersion as any); export default command; diff --git a/cli/src/commands/group/group.ts b/cli/src/commands/group/group.ts new file mode 100644 index 0000000000..5d06a7df1f --- /dev/null +++ b/cli/src/commands/group/group.ts @@ -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("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) + .command("create", "Create a new group") + .arguments("") + .option("--summary ", "Group summary/description") + .action(create as any) + .command("delete", "Delete a group") + .arguments("") + .action(deleteGroup as any) + .command("add-user", "Add a user to a group") + .arguments(" ") + .action(addUser as any) + .command("remove-user", "Remove a user from a group") + .arguments(" ") + .action(removeUser as any); + +export default command; diff --git a/cli/src/commands/job/job.ts b/cli/src/commands/job/job.ts new file mode 100644 index 0000000000..27bf02c90d --- /dev/null +++ b/cli/src/commands/job/job.ts @@ -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 ", "Filter by exact script/flow path") + .option("--created-by ", "Filter by creator username") + .option("--running", "Show only running jobs") + .option("--failed", "Show only failed jobs") + .option("--success ", "Filter by success status (true/false)") + .option("--limit ", "Number of jobs to return (default 30, max 100)") + .option("--job-kinds ", "Filter by job kinds (default: script,flow,singlestepflow)") + .option("--label ", "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("") + .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("") + .action(result as any) + .command("logs", "Get job logs") + .arguments("") + .action(logs as any) + .command("cancel", "Cancel a running or queued job") + .arguments("") + .option("--reason ", "Reason for cancellation") + .action(cancel as any); + +export default command; diff --git a/cli/src/commands/schedule/schedule.ts b/cli/src/commands/schedule/schedule.ts index 5a2b22efce..586581552c 100644 --- a/cli/src/commands/schedule/schedule.ts +++ b/cli/src/commands/schedule/schedule.ts @@ -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(" ") - .action(push as any); + .action(push as any) + .command("enable", "Enable a schedule") + .arguments("") + .action(enable as any) + .command("disable", "Disable a schedule") + .arguments("") + .action(disable as any); export default command; diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 49123a4c2d..ac498d039b 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -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 ", "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("") + .option("--json", "Output as JSON (for piping to jq)") + .action(history as any); export default command; diff --git a/cli/src/commands/token/token.ts b/cli/src/commands/token/token.ts new file mode 100644 index 0000000000..d0c8cda76d --- /dev/null +++ b/cli/src/commands/token/token.ts @@ -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 ", "Token label") + .option("--expiration ", "Token expiration (ISO 8601 timestamp)") + .action(create as any) + .command("delete", "Delete a token by its prefix") + .arguments("") + .action(deleteToken as any); + +export default command; diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 0d9da7dc3a..876383c96c 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -4999,6 +4999,16 @@ app related commands - \`--dry-run\` - Perform a dry run without making changes - \`--default-ts \` - Default TypeScript runtime (bun or deno) +### audit + +View audit logs (requires admin) + +**Subcommands:** + +- \`audit list\` - List audit log entries +- \`audit get \` - 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 \` - create a new empty flow (alias for new - \`--summary \` - flow summary - \`--description \` - flow description +- \`flow history \` - Show version history for a flow + - \`--json\` - Output as JSON (for piping to jq) +- \`flow show-version \` - 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 \` - 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 \` - Get group details and members + - \`--json\` - Output as JSON (for piping to jq) +- \`group create \` - Create a new group + - \`--summary \` - Group summary/description +- \`group delete \` - Delete a group +- \`group add-user \` - Add a user to a group +- \`group remove-user \` - 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 \` - Name of the instance, override the active instance +### job + +Manage jobs (list, inspect, cancel) + +**Subcommands:** + +- \`job list\` - List recent jobs +- \`job get \` - Get job details and result + - \`--json\` - Output as JSON (for piping to jq) +- \`job result \` - Get the result of a completed job (machine-friendly +- \`job logs \` - Get job logs +- \`job cancel \` - Cancel a running or queued job + - \`--reason \` - 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 \` - create a new schedule locally - \`schedule push \` - push a local schedule spec. This overrides any remote versions. +- \`schedule enable \` - Enable a schedule +- \`schedule disable \` - Disable a schedule ### script @@ -5309,6 +5358,8 @@ script related commands - \`--schema-only\` - re-generate only script schema - \`-i --includes \` - 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 \` - Comma separated patterns to specify which file to NOT take into account. +- \`script history \` - 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 \` - Token label + - \`--expiration \` - Token expiration (ISO 8601 timestamp) +- \`token delete \` - Delete a token by its prefix + ### trigger trigger related commands diff --git a/cli/src/main.ts b/cli/src/main.ts index 4446b53721..444ef0aa88 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -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) diff --git a/cli/src/utils/utils.ts b/cli/src/utils/utils.ts index 766b372a0a..96db9d6492 100644 --- a/cli/src/utils/utils.ts +++ b/cli/src/utils/utils.ts @@ -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); +} diff --git a/cli/test/audit_token_commands.test.ts b/cli/test/audit_token_commands.test.ts new file mode 100644 index 0000000000..66cc0b752f --- /dev/null +++ b/cli/test/audit_token_commands.test.ts @@ -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"); + }); + }); +}); diff --git a/cli/test/group_commands.test.ts b/cli/test/group_commands.test.ts new file mode 100644 index 0000000000..54f769c0a0 --- /dev/null +++ b/cli/test/group_commands.test.ts @@ -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"); + }); + }); +}); diff --git a/cli/test/job_commands.test.ts b/cli/test/job_commands.test.ts new file mode 100644 index 0000000000..9786dfff2b --- /dev/null +++ b/cli/test/job_commands.test.ts @@ -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"); + }); + }); +}); diff --git a/cli/test/new_commands_helpers.ts b/cli/test/new_commands_helpers.ts new file mode 100644 index 0000000000..ac92b1f54f --- /dev/null +++ b/cli/test/new_commands_helpers.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/cli/test/schedule_history_commands.test.ts b/cli/test/schedule_history_commands.test.ts new file mode 100644 index 0000000000..eca094a77d --- /dev/null +++ b/cli/test/schedule_history_commands.test.ts @@ -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(); + }); + }); +}); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 4c38b2ffc2..42f6343035 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -41,6 +41,16 @@ app related commands - `--dry-run` - Perform a dry run without making changes - `--default-ts ` - Default TypeScript runtime (bun or deno) +### audit + +View audit logs (requires admin) + +**Subcommands:** + +- `audit list` - List audit log entries +- `audit get ` - 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 ` - create a new empty flow (alias for new - `--summary ` - flow summary - `--description ` - flow description +- `flow history ` - Show version history for a flow + - `--json` - Output as JSON (for piping to jq) +- `flow show-version ` - 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 ` - 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 ` - Get group details and members + - `--json` - Output as JSON (for piping to jq) +- `group create ` - Create a new group + - `--summary ` - Group summary/description +- `group delete ` - Delete a group +- `group add-user ` - Add a user to a group +- `group remove-user ` - 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 ` - Name of the instance, override the active instance +### job + +Manage jobs (list, inspect, cancel) + +**Subcommands:** + +- `job list` - List recent jobs +- `job get ` - Get job details and result + - `--json` - Output as JSON (for piping to jq) +- `job result ` - Get the result of a completed job (machine-friendly +- `job logs ` - Get job logs +- `job cancel ` - Cancel a running or queued job + - `--reason ` - 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 ` - create a new schedule locally - `schedule push ` - push a local schedule spec. This overrides any remote versions. +- `schedule enable ` - Enable a schedule +- `schedule disable ` - Disable a schedule ### script @@ -351,6 +400,8 @@ script related commands - `--schema-only` - re-generate only script schema - `-i --includes ` - 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 ` - Comma separated patterns to specify which file to NOT take into account. +- `script history ` - 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 ` - Token label + - `--expiration ` - Token expiration (ISO 8601 timestamp) +- `token delete ` - Delete a token by its prefix + ### trigger trigger related commands diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index e6fa904052..0fe9b2b835 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1568,6 +1568,16 @@ app related commands - \`--dry-run\` - Perform a dry run without making changes - \`--default-ts \` - Default TypeScript runtime (bun or deno) +### audit + +View audit logs (requires admin) + +**Subcommands:** + +- \`audit list\` - List audit log entries +- \`audit get \` - 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 \` - create a new empty flow (alias for new - \`--summary \` - flow summary - \`--description \` - flow description +- \`flow history \` - Show version history for a flow + - \`--json\` - Output as JSON (for piping to jq) +- \`flow show-version \` - 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 \` - 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 \` - Get group details and members + - \`--json\` - Output as JSON (for piping to jq) +- \`group create \` - Create a new group + - \`--summary \` - Group summary/description +- \`group delete \` - Delete a group +- \`group add-user \` - Add a user to a group +- \`group remove-user \` - 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 \` - Name of the instance, override the active instance +### job + +Manage jobs (list, inspect, cancel) + +**Subcommands:** + +- \`job list\` - List recent jobs +- \`job get \` - Get job details and result + - \`--json\` - Output as JSON (for piping to jq) +- \`job result \` - Get the result of a completed job (machine-friendly +- \`job logs \` - Get job logs +- \`job cancel \` - Cancel a running or queued job + - \`--reason \` - 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 \` - create a new schedule locally - \`schedule push \` - push a local schedule spec. This overrides any remote versions. +- \`schedule enable \` - Enable a schedule +- \`schedule disable \` - Disable a schedule ### script @@ -1878,6 +1927,8 @@ script related commands - \`--schema-only\` - re-generate only script schema - \`-i --includes \` - 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 \` - Comma separated patterns to specify which file to NOT take into account. +- \`script history \` - 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 \` - Token label + - \`--expiration \` - Token expiration (ISO 8601 timestamp) +- \`token delete \` - Delete a token by its prefix + ### trigger trigger related commands diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 8b95426b37..072c8d5330 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -46,6 +46,16 @@ app related commands - `--dry-run` - Perform a dry run without making changes - `--default-ts ` - Default TypeScript runtime (bun or deno) +### audit + +View audit logs (requires admin) + +**Subcommands:** + +- `audit list` - List audit log entries +- `audit get ` - 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 ` - create a new empty flow (alias for new - `--summary ` - flow summary - `--description ` - flow description +- `flow history ` - Show version history for a flow + - `--json` - Output as JSON (for piping to jq) +- `flow show-version ` - 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 ` - 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 ` - Get group details and members + - `--json` - Output as JSON (for piping to jq) +- `group create ` - Create a new group + - `--summary ` - Group summary/description +- `group delete ` - Delete a group +- `group add-user ` - Add a user to a group +- `group remove-user ` - 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 ` - Name of the instance, override the active instance +### job + +Manage jobs (list, inspect, cancel) + +**Subcommands:** + +- `job list` - List recent jobs +- `job get ` - Get job details and result + - `--json` - Output as JSON (for piping to jq) +- `job result ` - Get the result of a completed job (machine-friendly +- `job logs ` - Get job logs +- `job cancel ` - Cancel a running or queued job + - `--reason ` - 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 ` - create a new schedule locally - `schedule push ` - push a local schedule spec. This overrides any remote versions. +- `schedule enable ` - Enable a schedule +- `schedule disable ` - Disable a schedule ### script @@ -356,6 +405,8 @@ script related commands - `--schema-only` - re-generate only script schema - `-i --includes ` - 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 ` - Comma separated patterns to specify which file to NOT take into account. +- `script history ` - 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 ` - Token label + - `--expiration ` - Token expiration (ISO 8601 timestamp) +- `token delete ` - Delete a token by its prefix + ### trigger trigger related commands