Files
windmill/cli/src/commands/trigger/trigger.ts
Ruben Fiszel c6ce3197a7 fix(cli): phantom diffs, flow safety, trigger DX, lint watch, error clarity (#8588)
* fix(cli): phantom diffs, flow push safety, error messages, digest stability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): differentiate stale vs missing metadata warnings on script push

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): job list --limit off-by-one, deps push double error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): flow get shows nested steps, lint works on specific directories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): add lint --watch mode for continuous validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): email trigger template missing local_part, trigger get shows all fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): fix CI — flow push warns instead of failing, lint subdir detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:56:10 +00:00

621 lines
18 KiB
TypeScript

import { mkdir, stat, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { stringify as yamlStringify } from "yaml";
import * as wmill from "../../../gen/services.gen.ts";
import {
GcpTrigger,
HttpTrigger,
KafkaTrigger,
MqttTrigger,
NatsTrigger,
PostgresTrigger,
SqsTrigger,
WebsocketTrigger,
EmailTrigger,
NativeTrigger,
NativeTriggerData,
NativeServiceName,
} from "../../../gen/types.gen.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 { sep as SEP } from "node:path";
import {
GlobalOptions,
isSuperset,
parseFromFile,
removeType,
TRIGGER_TYPES,
extractNativeTriggerInfo,
} from "../../types.ts";
import {
fromBranchSpecificPath,
isBranchSpecificFile,
} from "../../core/specific_items.ts";
import { getCurrentGitBranch } from "../../utils/git.ts";
import { requireLogin } from "../../core/auth.ts";
import { validatePath, resolveWorkspace } from "../../core/context.ts";
type Trigger = {
http: HttpTrigger;
websocket: WebsocketTrigger;
kafka: KafkaTrigger;
nats: NatsTrigger;
postgres: PostgresTrigger;
mqtt: MqttTrigger;
sqs: SqsTrigger;
gcp: GcpTrigger;
email: EmailTrigger;
};
type TriggerFile<K extends TriggerType> = Omit<
Trigger[K],
| "path"
| "workspace"
| "edited_by"
| "edited_at"
| "error"
| "last_server_ping"
| "server_id"
>;
type TriggerType = keyof Trigger;
async function getTrigger<K extends TriggerType>(
triggerType: K,
workspace: string,
path: string
): Promise<Trigger[K]> {
const triggerFunctions: {
[K in TriggerType]: (args: {
workspace: string;
path: string;
}) => Promise<Trigger[K]>;
} = {
http: wmill.getHttpTrigger,
websocket: wmill.getWebsocketTrigger,
kafka: wmill.getKafkaTrigger,
nats: wmill.getNatsTrigger,
postgres: wmill.getPostgresTrigger,
mqtt: wmill.getMqttTrigger,
sqs: wmill.getSqsTrigger,
gcp: wmill.getGcpTrigger,
email: wmill.getEmailTrigger,
};
const triggerFunction = triggerFunctions[triggerType];
const trigger = await triggerFunction({ workspace, path });
return trigger;
}
async function updateTrigger<K extends TriggerType>(
triggerType: K,
workspace: string,
path: string,
trigger: Trigger[K]
): Promise<void> {
const triggerFunctions: {
[K in TriggerType]: (args: {
workspace: string;
path: string;
requestBody: Trigger[K];
}) => Promise<any>;
} = {
http: wmill.updateHttpTrigger,
websocket: wmill.updateWebsocketTrigger,
kafka: wmill.updateKafkaTrigger,
nats: wmill.updateNatsTrigger,
postgres: wmill.updatePostgresTrigger,
mqtt: wmill.updateMqttTrigger,
sqs: wmill.updateSqsTrigger,
gcp: wmill.updateGcpTrigger,
email: wmill.updateEmailTrigger,
};
const triggerFunction = triggerFunctions[triggerType];
await triggerFunction({ workspace, path, requestBody: trigger });
}
async function createTrigger<K extends TriggerType>(
triggerType: K,
workspace: string,
path: string,
trigger: Trigger[K]
): Promise<void> {
const triggerFunctions: {
[K in TriggerType]: (args: {
workspace: string;
path: string;
requestBody: Trigger[K];
}) => Promise<any>;
} = {
http: wmill.createHttpTrigger,
websocket: wmill.createWebsocketTrigger,
kafka: wmill.createKafkaTrigger,
nats: wmill.createNatsTrigger,
postgres: wmill.createPostgresTrigger,
mqtt: wmill.createMqttTrigger,
sqs: wmill.createSqsTrigger,
gcp: wmill.createGcpTrigger,
email: wmill.createEmailTrigger,
};
const triggerFunction = triggerFunctions[triggerType];
await triggerFunction({ workspace, path, requestBody: trigger });
}
export async function pushTrigger<K extends TriggerType>(
triggerType: K,
workspace: string,
path: string,
trigger: TriggerFile<K> | Trigger[K] | undefined,
localTrigger: TriggerFile<K>
): Promise<void> {
path = removeType(path, triggerType + "_trigger").replaceAll(SEP, "/");
log.debug(`Processing local ${triggerType} trigger ${path}`);
try {
trigger = await getTrigger(triggerType, workspace, path);
log.debug(`${triggerType} trigger ${path} exists on remote`);
} catch {
log.debug(`${triggerType} trigger ${path} does not exist on remote`);
//ignore
}
if (trigger) {
if (isSuperset(localTrigger, trigger)) {
log.debug(`${triggerType} trigger ${path} is up to date`);
return;
}
log.debug(`${triggerType} trigger ${path} is not up-to-date, updating...`);
try {
await updateTrigger(triggerType, workspace, path, {
...localTrigger,
path,
} as Trigger[K]);
} catch (e) {
console.error((e as any).body);
throw e;
}
} else {
console.log(
colors.bold.yellow(`Creating new ${triggerType} trigger: ${path}`)
);
try {
await createTrigger(triggerType, workspace, path, {
...localTrigger,
path,
} as Trigger[K]);
} catch (e) {
console.error((e as any).body);
throw e;
}
}
}
type NativeTriggerFile = Omit<
NativeTrigger,
"external_id" | "workspace_id" | "error"
>;
export async function pushNativeTrigger(
workspace: string,
filePath: string,
_remoteTrigger: NativeTrigger | undefined,
localTrigger: NativeTriggerFile
): Promise<void> {
const triggerInfo = extractNativeTriggerInfo(filePath);
if (!triggerInfo) {
throw new Error(
`Invalid native trigger file path: ${filePath}. Expected format: {script_path}.{flow|script}.{external_id}.{service}_native_trigger.json`
);
}
const { externalId, serviceName } = triggerInfo;
log.debug(
`Processing local native trigger: service=${serviceName}, external_id=${externalId}`
);
let remoteTrigger: NativeTrigger | undefined;
try {
const result = await wmill.getNativeTrigger({
workspace,
serviceName: serviceName as NativeServiceName,
externalId,
});
// getNativeTrigger returns NativeTriggerWithExternal, extract NativeTrigger fields
remoteTrigger = {
external_id: result.external_id,
workspace_id: result.workspace_id,
service_name: result.service_name,
script_path: result.script_path,
is_flow: result.is_flow,
service_config: result.service_config,
error: result.error,
summary: result.summary,
};
log.debug(`Native trigger ${serviceName}/${externalId} exists on remote`);
} catch {
log.debug(
`Native trigger ${serviceName}/${externalId} does not exist on remote`
);
}
const triggerData: NativeTriggerData = {
script_path: localTrigger.script_path,
is_flow: localTrigger.is_flow,
service_config: localTrigger.service_config,
summary: localTrigger.summary,
};
if (remoteTrigger) {
// Compare relevant fields
const localCompare = {
script_path: localTrigger.script_path,
is_flow: localTrigger.is_flow,
service_config: localTrigger.service_config,
summary: localTrigger.summary,
};
const remoteCompare = {
script_path: remoteTrigger.script_path,
is_flow: remoteTrigger.is_flow,
service_config: remoteTrigger.service_config,
summary: remoteTrigger.summary,
};
if (isSuperset(localCompare, remoteCompare)) {
log.debug(`Native trigger ${serviceName}/${externalId} is up to date`);
return;
}
log.debug(
`Native trigger ${serviceName}/${externalId} is not up-to-date, updating...`
);
try {
await wmill.updateNativeTrigger({
workspace,
serviceName: serviceName as NativeServiceName,
externalId,
requestBody: triggerData,
});
} catch (e) {
console.error((e as any).body);
throw e;
}
} else {
console.log(
colors.bold.yellow(
`Creating new native trigger: ${serviceName}/${externalId}`
)
);
try {
await wmill.createNativeTrigger({
workspace,
serviceName: serviceName as NativeServiceName,
requestBody: triggerData,
});
} catch (e) {
console.error((e as any).body);
throw e;
}
}
}
const triggerTemplates: Record<TriggerType, Record<string, any>> = {
http: {
script_path: "",
is_flow: false,
route_path: "",
http_method: "get",
is_async: false,
requires_auth: true,
request_type: "sync",
authentication_method: "none",
is_static_website: false,
workspaced_route: false,
wrap_body: false,
raw_string: false,
},
websocket: {
script_path: "",
is_flow: false,
url: "",
filters: [],
can_return_message: false,
can_return_error_result: false,
enabled: false,
},
kafka: {
script_path: "",
is_flow: false,
kafka_resource_path: "",
group_id: "",
topics: [],
filters: [],
enabled: false,
},
nats: {
script_path: "",
is_flow: false,
nats_resource_path: "",
subjects: [],
use_jetstream: false,
enabled: false,
},
postgres: {
script_path: "",
is_flow: false,
postgres_resource_path: "",
publication_name: "",
replication_slot_name: "",
enabled: false,
},
mqtt: {
script_path: "",
is_flow: false,
mqtt_resource_path: "",
subscribe_topics: [],
enabled: false,
},
sqs: {
script_path: "",
is_flow: false,
queue_url: "",
aws_resource_path: "",
aws_auth_resource_type: "credentials",
enabled: false,
},
gcp: {
script_path: "",
is_flow: false,
gcp_resource_path: "",
topic_id: "",
subscription_id: "",
delivery_type: "pull",
subscription_mode: "create_update",
enabled: false,
},
email: {
script_path: "",
is_flow: false,
local_part: "",
enabled: false,
},
};
async function newTrigger(opts: GlobalOptions & { kind: string }, path: string) {
if (!validatePath(path)) {
return;
}
if (!opts.kind) {
throw new Error("--kind is required. Valid kinds: " + TRIGGER_TYPES.join(", "));
}
if (!checkIfValidTrigger(opts.kind)) {
throw new Error("Invalid trigger kind: " + opts.kind + ". Valid kinds: " + TRIGGER_TYPES.join(", "));
}
const kind: TriggerType = opts.kind;
const filePath = `${path}.${kind}_trigger.yaml`;
try {
await stat(filePath);
throw new Error("File already exists: " + filePath);
} catch (e: any) {
if (e.message?.startsWith("File already exists")) throw e;
}
const template = triggerTemplates[kind];
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, yamlStringify(template), {
flag: "wx",
encoding: "utf-8",
});
log.info(colors.green(`Created ${filePath}`));
}
const TRIGGER_SKIP_FIELDS = new Set(["workspace_id", "extra_perms", "edited_by", "edited_at"]);
function printTriggerDetails(trigger: any, kind: string) {
console.log(colors.bold("Path:") + " " + trigger.path);
console.log(colors.bold("Kind:") + " " + kind);
console.log(colors.bold("Enabled:") + " " + (trigger.enabled ?? trigger.mode ?? "-"));
console.log(colors.bold("Script Path:") + " " + (trigger.script_path ?? ""));
console.log(colors.bold("Is Flow:") + " " + (trigger.is_flow ? "true" : "false"));
// Show all other non-internal fields
for (const [key, value] of Object.entries(trigger)) {
if (["path", "enabled", "mode", "script_path", "is_flow"].includes(key)) continue;
if (TRIGGER_SKIP_FIELDS.has(key)) continue;
if (value === undefined || value === null || value === "") continue;
const display = Array.isArray(value) ? (value.length > 0 ? JSON.stringify(value) : "[]") :
typeof value === "object" ? JSON.stringify(value) : String(value);
if (display === "[]" || display === "{}") continue;
const label = key.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
console.log(colors.bold(label + ":") + " " + display);
}
}
async function get(opts: GlobalOptions & { json?: boolean; kind?: string }, path: string) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (opts.kind) {
if (!checkIfValidTrigger(opts.kind)) {
throw new Error("Invalid trigger kind: " + opts.kind + ". Valid kinds: " + TRIGGER_TYPES.join(", "));
}
const trigger = await getTrigger(opts.kind, workspace.workspaceId, path);
if (opts.json) {
console.log(JSON.stringify(trigger));
} else {
printTriggerDetails(trigger as any, opts.kind);
}
return;
}
// Try all trigger types and collect matches
const matches: { kind: string; trigger: any }[] = [];
for (const kind of TRIGGER_TYPES) {
try {
const trigger = await getTrigger(kind, workspace.workspaceId, path);
matches.push({ kind, trigger });
} catch {
// not found for this kind
}
}
if (matches.length === 0) {
throw new Error("No trigger found at path: " + path);
}
if (matches.length === 1) {
const { kind, trigger } = matches[0];
if (opts.json) {
console.log(JSON.stringify(trigger));
} else {
printTriggerDetails(trigger, kind);
}
return;
}
// Multiple matches — ask user to specify --kind
console.log("Multiple triggers found at path " + path + ":");
for (const m of matches) {
console.log(" - " + m.kind);
}
console.log("Please specify --kind <type> to select one.");
}
async function listOrEmpty<T>(fn: () => Promise<T[]>): Promise<T[]> {
try {
return await fn();
} catch {
return [];
}
}
async function list(opts: GlobalOptions & { json?: boolean }) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const ws = workspace.workspaceId;
const [
httpTriggers,
websocketTriggers,
kafkaTriggers,
natsTriggers,
postgresTriggers,
mqttTriggers,
sqsTriggers,
gcpTriggers,
emailTriggers,
] = await Promise.all([
listOrEmpty(() => wmill.listHttpTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listWebsocketTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listKafkaTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listNatsTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listPostgresTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listMqttTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listSqsTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listGcpTriggers({ workspace: ws })),
listOrEmpty(() => wmill.listEmailTriggers({ workspace: ws })),
]);
const triggers = [
...httpTriggers.map((x) => ({ path: x.path, kind: "http" })),
...websocketTriggers.map((x) => ({ path: x.path, kind: "websocket" })),
...kafkaTriggers.map((x) => ({ path: x.path, kind: "kafka" })),
...natsTriggers.map((x) => ({ path: x.path, kind: "nats" })),
...postgresTriggers.map((x) => ({ path: x.path, kind: "postgres" })),
...mqttTriggers.map((x) => ({ path: x.path, kind: "mqtt" })),
...sqsTriggers.map((x) => ({ path: x.path, kind: "sqs" })),
...gcpTriggers.map((x) => ({ path: x.path, kind: "gcp" })),
...emailTriggers.map((x) => ({ path: x.path, kind: "email" })),
];
if (opts.json) {
console.log(JSON.stringify(triggers));
} else {
new Table()
.header(["Path", "Kind"])
.padding(2)
.border(true)
.body(triggers.map((x) => [x.path, x.kind]))
.render();
}
}
function checkIfValidTrigger(kind: string | undefined): kind is TriggerType {
if (kind && (TRIGGER_TYPES as readonly string[]).includes(kind)) {
return true;
} else {
return false;
}
}
function extractTriggerKindFromPath(filePath: string): string | undefined {
let pathToAnalyze = filePath;
// If this is a branch-specific file, convert it to the base path first
if (isBranchSpecificFile(filePath)) {
const currentBranch = getCurrentGitBranch();
if (currentBranch) {
pathToAnalyze = fromBranchSpecificPath(filePath, currentBranch);
}
}
// Now extract trigger type from the base path: "something.kafka_trigger.yaml" -> "kafka"
const triggerMatch = pathToAnalyze.match(/\.(\w+)_trigger\.yaml$/);
return triggerMatch ? triggerMatch[1] : undefined;
}
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (!validatePath(remotePath)) {
return;
}
const fstat = await stat(filePath);
if (!fstat.isFile()) {
throw new Error("file path must refer to a file.");
}
console.log(colors.bold.yellow("Pushing trigger..."));
const triggerKind = extractTriggerKindFromPath(filePath);
if (!checkIfValidTrigger(triggerKind)) {
throw new Error("Invalid trigger kind: " + triggerKind);
}
await pushTrigger(
triggerKind,
workspace.workspaceId,
remotePath,
undefined,
parseFromFile(filePath)
);
console.log(colors.bold.underline.green("Trigger pushed"));
}
const command = new Command()
.description("trigger related commands")
.option("--json", "Output as JSON (for piping to jq)")
.action(list as any)
.command("list", "list all triggers")
.option("--json", "Output as JSON (for piping to jq)")
.action(list as any)
.command("get", "get a trigger's details")
.arguments("<path:string>")
.option("--json", "Output as JSON (for piping to jq)")
.option("--kind <kind:string>", "Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email). Recommended for faster lookup")
.action(get as any)
.command("new", "create a new trigger locally")
.arguments("<path:string>")
.option("--kind <kind:string>", "Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)")
.action(newTrigger as any)
.command(
"push",
"push a local trigger spec. This overrides any remote versions."
)
.arguments("<file_path:string> <remote_path:string>")
.action(push as any);
export default command;