Merge remote-tracking branch 'origin/main' into fork-datatable-schema-export

This commit is contained in:
Diego Imbert
2026-03-31 21:26:21 +02:00
650 changed files with 34372 additions and 6727 deletions
+21 -2
View File
@@ -5,6 +5,7 @@ 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 { stat } from "node:fs/promises";
import * as windmillUtils from "@windmill-labs/shared-utils";
import { yamlParseFile } from "../../utils/yaml.ts";
import * as wmill from "../../../gen/services.gen.ts";
@@ -241,8 +242,26 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
await pushApp(workspace.workspaceId, remotePath, filePath);
log.info(colors.bold.underline.green("App pushed"));
// Detect raw apps by checking for raw_app.yaml or __raw_app/.raw_app suffix
const normalizedPath = filePath.endsWith(SEP) ? filePath.slice(0, -1) : filePath;
const isRawApp = normalizedPath.endsWith("__raw_app") || normalizedPath.endsWith(".raw_app");
let hasRawAppYaml = false;
if (!isRawApp) {
try {
const rawAppPath = (filePath.endsWith(SEP) ? filePath : filePath + SEP) + "raw_app.yaml";
await stat(rawAppPath);
hasRawAppYaml = true;
} catch { /* not a raw app */ }
}
if (isRawApp || hasRawAppYaml) {
const { pushRawApp } = await import("./raw_apps.ts");
await pushRawApp(workspace.workspaceId, remotePath, filePath);
log.info(colors.bold.underline.green("Raw app pushed"));
} else {
await pushApp(workspace.workspaceId, remotePath, filePath);
log.info(colors.bold.underline.green("App pushed"));
}
}
const command = new Command()
+4 -3
View File
@@ -166,8 +166,10 @@ export async function createBundle(
// Dynamically import esbuild
const esbuild = await import("esbuild");
// Detect frameworks to determine default entry point
const frameworks = detectFrameworks(process.cwd());
// Detect frameworks to determine default entry point.
// Use the entryPoint's directory if provided, otherwise fall back to cwd.
const appDir = options.entryPoint ? path.dirname(options.entryPoint) : process.cwd();
const frameworks = detectFrameworks(appDir);
const defaultEntry = (frameworks.svelte || frameworks.vue) ? "index.ts" : "index.tsx";
const entryPoint = options.entryPoint ?? defaultEntry;
@@ -184,7 +186,6 @@ export async function createBundle(
}
// Ensure node_modules exists in the app directory
const appDir = path.dirname(entryPoint) || process.cwd();
await ensureNodeModules(appDir);
// Load framework-specific plugins (svelte, vue) based on package.json
+5 -1
View File
@@ -6,7 +6,7 @@ import { colors } from "@cliffy/ansi/colors";
import * as log from "../../core/log.ts";
import { yamlParseFile } from "../../utils/yaml.ts";
import { GlobalOptions } from "../../types.ts";
import { createBundle } from "./bundle.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
import {
@@ -113,7 +113,11 @@ async function validateBuild(
log.info(colors.blue("🔨 Testing build..."));
// Try to create a bundle - this will validate that all dependencies are in place
const frameworks = detectFrameworks(appDir);
const entryFile = frameworks.svelte || frameworks.vue ? "index.ts" : "index.tsx";
const entryPoint = path.join(appDir, entryFile);
await createBundle({
entryPoint,
production: true,
minify: false,
});
+120
View File
@@ -0,0 +1,120 @@
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;
}
if (logs.every((l) => l.operation === "redacted")) {
log.info(colors.yellow(
"Audit log details are not available on the Community Edition.\n" +
"Upgrade to the Enterprise Edition for full audit logging with operation details."
));
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;
+26
View File
@@ -0,0 +1,26 @@
import { Command } from "@cliffy/command";
import * as log from "../../core/log.ts";
import {
formatConfigReference,
formatConfigReferenceJson,
} from "../init/template.ts";
interface ConfigOptions {
json?: boolean;
}
async function configAction(opts: ConfigOptions) {
if (opts.json) {
console.log(formatConfigReferenceJson());
} else {
log.info(formatConfigReference());
}
}
const command = new Command()
.name("config")
.description("Show all available wmill.yaml configuration options")
.option("--json", "Output as JSON for programmatic consumption")
.action(configAction as any);
export default command;
+54 -58
View File
@@ -43,70 +43,66 @@ export async function pushWorkspaceDependencies(
_befObj: any,
newDependenciesContent: string,
): Promise<void> {
try {
const res = workspaceDependenciesPathToLanguageAndFilename(path);
if (!res) {
throw new Error(`Unknown workspace dependencies file format: ${path}`);
}
const { language, name } = res;
const displayName = name
? `named dependencies "${name}"`
: `workspace default dependencies`;
// Fetch remote workspace dependencies and compare content directly
try {
const remoteDeps = await wmill.getLatestWorkspaceDependencies({
workspace,
language,
name,
});
if (remoteDeps && remoteDeps.content === newDependenciesContent) {
log.info(
colors.green(
`${displayName} for ${language} are up-to-date, skipping push`,
),
);
return;
}
} catch (e: any) {
// If 404 or not found, the dependency doesn't exist remotely yet - proceed with push
if (e.status !== 404 && !e.message?.includes("not found")) {
throw e;
}
}
log.info(
colors.yellow(
`Pushing ${
name ? "named" : "workspace default"
} dependencies for ${language}...`,
),
const res = workspaceDependenciesPathToLanguageAndFilename(path);
if (!res) {
throw new Error(
`Unknown workspace dependencies file format: ${path}. ` +
`Valid files: package.json, requirements.in, composer.json, go.mod, modules.json`
);
}
await wmill.createWorkspaceDependencies({
const { language, name } = res;
const displayName = name
? `named dependencies "${name}"`
: `workspace default dependencies`;
// Fetch remote workspace dependencies and compare content directly
try {
const remoteDeps = await wmill.getLatestWorkspaceDependencies({
workspace,
requestBody: {
name,
content: newDependenciesContent,
language,
workspace_id: workspace,
// Description is not supported in cli, it will use old description
description: undefined,
},
language,
name,
});
log.info(
colors.green(`Successfully pushed ${displayName} for ${language}`),
);
} catch (error: any) {
log.error(
colors.red(`Failed to push workspace dependencies: ${error.message}`),
);
throw error;
if (remoteDeps && remoteDeps.content === newDependenciesContent) {
log.info(
colors.green(
`${displayName} for ${language} are up-to-date, skipping push`,
),
);
return;
}
} catch (e: any) {
// If 404 or not found, the dependency doesn't exist remotely yet - proceed with push
if (e.status !== 404 && !e.message?.includes("not found")) {
throw e;
}
}
log.info(
colors.yellow(
`Pushing ${
name ? "named" : "workspace default"
} dependencies for ${language}...`,
),
);
await wmill.createWorkspaceDependencies({
workspace,
requestBody: {
name,
content: newDependenciesContent,
language,
workspace_id: workspace,
// Description is not supported in cli, it will use old description
description: undefined,
},
});
log.info(
colors.green(`Successfully pushed ${displayName} for ${language}`),
);
}
export default command;
+1 -1
View File
@@ -236,7 +236,7 @@ async function dev(opts: GlobalOptions & SyncOptions) {
}
const command = new Command()
.description("Launch a dev server that will spawn a webserver with HMR")
.description("Launch a dev server that watches for local file changes and auto-pushes them to the remote workspace. Provides live reload for scripts and flows during development.")
.option(
"--includes <pattern...:string>",
"Filter paths givena glob pattern or path"
+1 -1
View File
@@ -106,7 +106,7 @@ async function docs(
const command = new Command()
.name("docs")
.description("Search Windmill documentation. Requires Enterprise Edition.")
.description("Search Windmill documentation.")
.arguments("<query:string>")
.option("--json", "Output results as JSON.")
.action(docs as any);
+283 -30
View File
@@ -7,9 +7,11 @@ import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import { stringify as yamlStringify } from "yaml";
import { yamlParseFile } from "../../utils/yaml.ts";
import { validateRequiredArgs } from "../../utils/utils.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { readFile } from "node:fs/promises";
import { mkdirSync, writeFileSync } from "node:fs";
import { buildFolderPath, getMetadataFileName, loadNonDottedPathsSetting } from "../../utils/resource_folders.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
@@ -152,18 +154,27 @@ export async function pushFlow(
const localFlow = (await yamlParseFile(localPath + "flow.yaml")) as FlowFile;
const fileReader = async (path: string) => await readFile(localPath + path, "utf-8");
const missingFiles: string[] = [];
await replaceInlineScripts(
localFlow.value.modules,
fileReader,
log,
localPath,
SEP
SEP,
undefined,
missingFiles
);
if (localFlow.value.failure_module) {
await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, localPath, SEP);
await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, localPath, SEP, undefined, missingFiles);
}
if (localFlow.value.preprocessor_module) {
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP);
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP, undefined, missingFiles);
}
if (missingFiles.length > 0) {
log.warn(colors.yellow(
`Warning: missing inline script file(s): ${missingFiles.join(", ")}. ` +
`The flow will be pushed with unresolved !inline references.`
));
}
if (flow) {
@@ -203,20 +214,21 @@ export async function pushFlow(
type Options = GlobalOptions;
async function push(opts: Options, filePath: string, remotePath: string) {
async function push(opts: Options & { message?: string }, filePath: string, remotePath: string) {
if (!validatePath(remotePath)) {
return;
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
await pushFlow(workspace.workspaceId, remotePath, filePath);
await pushFlow(workspace.workspaceId, remotePath, filePath, opts.message);
log.info(colors.bold.underline.green("Flow pushed"));
}
async function list(
opts: GlobalOptions & { showArchived?: boolean; includeDraftOnly?: boolean; json?: boolean }
) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
@@ -250,6 +262,7 @@ async function list(
}
}
async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const f = await wmill.getFlowByPath({
@@ -264,6 +277,31 @@ async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
console.log(colors.bold("Description:") + " " + (f.description ?? ""));
console.log(colors.bold("Edited by:") + " " + (f.edited_by ?? ""));
console.log(colors.bold("Edited at:") + " " + (f.edited_at ?? ""));
// API response type doesn't include flow value/modules — cast needed to access them
const modules = (f as any).value?.modules;
if (modules && Array.isArray(modules) && modules.length > 0) {
console.log(colors.bold("Steps:"));
function printModules(mods: any[], indent: string = " ") {
for (const mod of mods) {
const type = mod.value?.type ?? "unknown";
const detail = mod.value?.language ?? mod.value?.path ?? "";
console.log(`${indent}${mod.id}: ${type}${detail ? " (" + detail + ")" : ""}`);
if (type === "branchall" || type === "branchone") {
for (const branch of mod.value?.branches ?? []) {
console.log(`${indent} Branch: ${branch.summary || "(default)"}`);
if (branch.modules) printModules(branch.modules, indent + " ");
}
if (type === "branchone" && mod.value?.default) {
console.log(`${indent} Default:`);
printModules(mod.value.default, indent + " ");
}
} else if (type === "forloopflow" || type === "whileloopflow") {
if (mod.value?.modules) printModules(mod.value.modules, indent + " ");
}
}
}
printModules(modules);
}
}
}
@@ -274,54 +312,177 @@ async function run(
},
path: string
) {
if (opts.silent) {
log.setSilent(true);
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const input = opts.data ? await resolve(opts.data) : {};
// Validate required args against schema when no data provided
if (!opts.data) {
try {
const flow = await wmill.getFlowByPath({
workspace: workspace.workspaceId,
path,
});
validateRequiredArgs(flow.schema as Record<string, unknown>);
} catch (e: any) {
if (e.message?.startsWith("Missing required")) throw e;
log.warn(`Could not fetch schema to validate args: ${e.message}`);
}
}
const id = await wmill.runFlowByPath({
workspace: workspace.workspaceId,
path,
requestBody: input,
});
// Build step label map from raw_flow if available
const stepLabels = new Map<string, string>();
try {
const initialJob = await wmill.getJob({
workspace: workspace.workspaceId,
id,
});
const rawFlow = (initialJob as any).raw_flow;
if (rawFlow?.modules) {
for (const mod of rawFlow.modules) {
if (mod.id) {
const label = mod.summary ? `${mod.id}: ${mod.summary}` : mod.id;
stepLabels.set(mod.id, label);
}
}
}
} catch {
// Best-effort — fall back to module IDs
}
let i = 0;
let lastStatus = "";
while (true) {
const jobInfo = await wmill.getJob({
workspace: workspace.workspaceId,
id,
});
if (jobInfo.flow_status!.modules.length <= i) {
// Check if flow has completed (success or failure)
const isCompleted = (jobInfo as any).type === "CompletedJob";
const flowStatus = jobInfo.flow_status!;
if (flowStatus.modules.length <= i) {
break;
}
const module = jobInfo.flow_status!.modules[i];
const module = flowStatus.modules[i];
if (module.job) {
if (!opts.silent) {
log.info("====== Job " + (i + 1) + " ======");
// If a module has failed, track its job (to show error logs), then break
if (module.type === "Failure") {
if (module.job && !opts.silent) {
const label = stepLabels.get(module.id!) ?? `Step ${i + 1}`;
log.info("====== " + label + " ======");
await track_job(workspace.workspaceId, module.job);
}
break;
}
if (module.job) {
const label = stepLabels.get(module.id!) ?? `Step ${i + 1}`;
const isForLoop = (module as any).flow_jobs !== undefined;
if (isForLoop) {
// For-loop: track iterations as they appear, re-polling until module completes
let trackedIterations = 0;
let forLoopFailed = false;
while (true) {
const refreshed = await wmill.getJob({
workspace: workspace.workspaceId,
id,
});
const refreshedModule = refreshed.flow_status!.modules[i];
const flowJobs = ((refreshedModule as any).flow_jobs as string[] | undefined) ?? [];
// Track any new iterations
while (trackedIterations < flowJobs.length) {
if (!opts.silent) {
log.info(`====== ${label} (iteration ${trackedIterations}) ======`);
await track_job(workspace.workspaceId, flowJobs[trackedIterations]);
}
trackedIterations++;
}
if (refreshedModule.type === "Success" || refreshedModule.type === "Failure") {
forLoopFailed = refreshedModule.type === "Failure";
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (forLoopFailed) break;
} else {
if (!opts.silent) {
log.info("====== " + label + " ======");
await track_job(workspace.workspaceId, module.job);
}
}
} else {
if (!opts.silent) {
log.info(module.type);
// Module not started yet — deduplicate status messages
const status = String(module.type);
if (!opts.silent && status !== lastStatus) {
log.info(colors.dim(status));
lastStatus = status;
}
await new Promise((resolve, _) =>
setTimeout(() => resolve(undefined), 100)
);
// If flow already completed while we were waiting, break out
if (isCompleted) break;
continue;
}
lastStatus = "";
i++;
}
if (!opts.silent) {
log.info(colors.green.underline.bold("Flow ran to completion"));
log.info("\n");
// Wait for flow completion with retry (handles race when --silent skips module tracking)
const MAX_RETRIES = 600; // ~60 seconds at 100ms intervals
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const jobInfo = await wmill.getCompletedJob({
workspace: workspace.workspaceId,
id,
});
if (!opts.silent) {
if (jobInfo.success === false) {
log.info(colors.red.underline.bold("Flow failed"));
} else {
log.info(colors.green.underline.bold("Flow ran to completion"));
}
log.info("\n");
}
if (jobInfo.success === false) {
process.exitCode = 1;
}
if (opts.silent) {
console.log(JSON.stringify(jobInfo.result ?? {}));
} else {
log.info(JSON.stringify(jobInfo.result ?? {}, null, 2));
}
break;
} catch {
retries++;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (retries >= MAX_RETRIES) {
throw new Error(`Timed out waiting for flow ${id} to complete`);
}
const jobInfo = await wmill.getCompletedJob({
workspace: workspace.workspaceId,
id,
});
log.info(JSON.stringify(jobInfo.result ?? {}, null, 2));
}
async function preview(
@@ -332,6 +493,9 @@ async function preview(
} & SyncOptions,
flowPath: string
) {
if (opts.silent) {
log.setSilent(true);
}
const useLocalPathScripts = !opts.remote;
if (useLocalPathScripts) {
opts = await mergeConfigWithConfigFile(opts);
@@ -340,14 +504,16 @@ async function preview(
await requireLogin(opts);
const codebases = useLocalPathScripts ? listSyncCodebases(opts) : [];
// Normalize path - ensure it's a directory path to a .flow folder
if (!flowPath.endsWith(".flow") && !flowPath.endsWith(".flow" + SEP)) {
// Normalize path - ensure it's a directory path to a .flow or __flow folder
const isFlowDir = flowPath.endsWith(".flow") || flowPath.endsWith(".flow" + SEP)
|| flowPath.endsWith("__flow") || flowPath.endsWith("__flow" + SEP);
if (!isFlowDir) {
// Check if it's a flow.yaml file
if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) {
flowPath = flowPath.substring(0, flowPath.lastIndexOf(SEP));
} else {
throw new Error(
"Flow path must be a .flow directory or a flow.yaml file"
"Flow path must be a .flow/__flow directory or a flow.yaml file"
);
}
}
@@ -421,13 +587,23 @@ async function preview(
});
} catch (e: any) {
if (e.body) {
log.error(`Flow preview failed: ${JSON.stringify(e.body)}`);
// If a failure_module ran, the body contains its result — not an error
if (e.body.result !== undefined) {
if (opts.silent) {
console.log(JSON.stringify(e.body.result));
} else {
log.info(colors.yellow.bold("Flow failed, error handler result:"));
log.info(JSON.stringify(e.body.result, null, 2));
}
process.exitCode = 1;
return;
}
}
throw e;
}
if (opts.silent) {
console.log(JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
} else {
log.info(colors.bold.underline.green("Flow preview completed"));
log.info(JSON.stringify(result, null, 2));
@@ -516,7 +692,7 @@ export async function generateLocks(
}
}
export function bootstrap(
export async function bootstrap(
opts: GlobalOptions & { summary: string; description: string },
flowPath: string
) {
@@ -524,8 +700,10 @@ export function bootstrap(
return;
}
const flowDirFullPath = `${flowPath}.flow`;
mkdirSync(flowDirFullPath, { recursive: false });
await loadNonDottedPathsSetting();
const flowDirFullPath = buildFolderPath(flowPath, "flow");
mkdirSync(flowDirFullPath, { recursive: true });
const newFlowDefinition = defaultFlowDefinition();
if (opts.summary !== undefined) {
@@ -539,10 +717,76 @@ export function bootstrap(
newFlowDefinition as Record<string, any>
);
const flowYamlPath = `${flowDirFullPath}/flow.yaml`;
const metadataFile = getMetadataFileName("flow", "yaml");
const flowYamlPath = `${flowDirFullPath}/${metadataFile}`;
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")
@@ -561,6 +805,7 @@ const command = new Command()
"push a local flow spec. This overrides any remote versions."
)
.arguments("<file_path:string> <remote_path:string>")
.option("--message <message:string>", "Deployment message")
.action(push as any)
.command("run", "run a flow by path.")
.arguments("<path:string>")
@@ -616,6 +861,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;
+27 -6
View File
@@ -18,12 +18,12 @@ import {
filterWorkspaceDependenciesForScripts,
} from "../../utils/metadata.ts";
import { ScriptLanguage } from "../../utils/script_common.ts";
import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
import { generateHash, getHeaders, writeIfChanged } from "../../utils/utils.ts";
import { exts } from "../script/script.ts";
import { FSFSElement } from "../sync/sync.ts";
import { FSFSElement, yamlOptions } from "../sync/sync.ts";
import { Workspace } from "../workspace/workspace.ts";
import { FlowFile } from "./flow.ts";
import { FlowValue } from "../../../gen/types.gen.ts";
@@ -188,6 +188,17 @@ export async function generateFlowLockInternal(
log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`);
}
const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8");
// Capture existing module-ID-to-file-path mapping before replaceInlineScripts
// overwrites the !inline references with actual file content. This preserves
// the original filenames when re-extracting inline scripts after lock generation.
const currentMapping = extractCurrentMapping(
flowValue.value.modules,
{},
flowValue.value.failure_module,
flowValue.value.preprocessor_module,
);
// In tree mode, use the tree's staleness info (which includes transitive dependency changes)
// to determine which scripts need relocking, instead of only content-changed ones.
const locksToRemove = (tree && !legacyBehaviour)
@@ -215,6 +226,12 @@ export async function generateFlowLockInternal(
//removeChangedLocks
const tempScriptRefs = tree?.getTempScriptRefs(folderNormalized);
// Preserve notes and groups — the backend round-trips through FlowValue
// which doesn't include these fields, so they'd be lost (#8641).
const savedNotes = flowValue.value.notes;
const savedGroups = flowValue.value.groups;
flowValue.value = await updateFlow(
workspace,
flowValue.value,
@@ -223,21 +240,25 @@ export async function generateFlowLockInternal(
tempScriptRefs
);
// Restore notes and groups that the backend stripped
if (savedNotes !== undefined) flowValue.value.notes = savedNotes;
if (savedGroups !== undefined) flowValue.value.groups = savedGroups;
const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun", {
skipInlineScriptSuffix: getNonDottedPaths(),
});
const inlineScripts = extractInlineScriptsForFlows(
flowValue.value.modules,
{},
currentMapping,
SEP,
opts.defaultTs,
lockAssigner
);
if (flowValue.value.failure_module) {
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], {}, SEP, opts.defaultTs, lockAssigner));
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner));
}
if (flowValue.value.preprocessor_module) {
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], {}, SEP, opts.defaultTs, lockAssigner));
inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner));
}
inlineScripts.forEach((s) => {
writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content);
@@ -246,7 +267,7 @@ export async function generateFlowLockInternal(
// Overwrite `flow.yaml` with the new lockfile references
writeIfChanged(
process.cwd() + SEP + folder + SEP + "flow.yaml",
yamlStringify(flowValue as Record<string, any>)
yamlStringify(flowValue as Record<string, any>, yamlOptions)
);
}
+1
View File
@@ -22,6 +22,7 @@ export interface FolderFile {
}
async function list(opts: GlobalOptions & { json?: boolean }) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
@@ -355,71 +355,102 @@ async function generateMetadata(
return colors.dim(colors.white(`[${n}/${total}]`.padEnd(maxWidth, " ")));
};
const errors: { path: string; error: string }[] = [];
// Process scripts
for (const item of scripts) {
current++;
log.info(`${formatProgress(current)} script ${item.path}`);
await generateScriptMetadataInternal(
item.path, // originalPath with extension
workspace,
opts,
false, // dryRun
true, // noStaleMessage
mismatchedWorkspaceDeps,
codebases,
false,
false, // legacyBehaviour
tree
);
try {
await generateScriptMetadataInternal(
item.path, // originalPath with extension
workspace,
opts,
false, // dryRun
true, // noStaleMessage
mismatchedWorkspaceDeps,
codebases,
false,
false, // legacyBehaviour
tree
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
errors.push({ path: item.path, error: msg });
log.error(` Failed: ${msg}`);
}
}
// Process flows
for (const item of flows) {
current++;
const result = await generateFlowLockInternal(
item.folder.replaceAll("/", SEP),
false, // dryRun
workspace,
opts,
false,
true, // noStaleMessage
false, // legacyBehaviour
tree
);
const flowResult = result as FlowLocksResult | undefined;
const scriptsInfo = flowResult?.updatedScripts?.length
? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`))
: "";
log.info(`${formatProgress(current)} flow ${item.path}${scriptsInfo}`);
try {
const result = await generateFlowLockInternal(
item.folder.replaceAll("/", SEP),
false, // dryRun
workspace,
opts,
false,
true, // noStaleMessage
false, // legacyBehaviour
tree
);
const flowResult = result as FlowLocksResult | undefined;
const scriptsInfo = flowResult?.updatedScripts?.length
? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`))
: "";
log.info(`${formatProgress(current)} flow ${item.path}${scriptsInfo}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
errors.push({ path: item.path, error: msg });
log.info(`${formatProgress(current)} flow ${item.path}`);
log.error(` Failed: ${msg}`);
}
}
// Process apps
for (const item of apps) {
current++;
const result = await generateAppLocksInternal(
item.folder.replaceAll("/", SEP),
item.isRawApp!, // rawApp
false, // dryRun
workspace,
opts,
false,
true, // noStaleMessage
false, // legacyBehaviour
tree
);
const appResult = result as AppLocksResult | undefined;
const scriptsInfo = appResult?.updatedScripts?.length
? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`))
: "";
log.info(`${formatProgress(current)} app ${item.path}${scriptsInfo}`);
try {
const result = await generateAppLocksInternal(
item.folder.replaceAll("/", SEP),
item.isRawApp!, // rawApp
false, // dryRun
workspace,
opts,
false,
true, // noStaleMessage
false, // legacyBehaviour
tree
);
const appResult = result as AppLocksResult | undefined;
const scriptsInfo = appResult?.updatedScripts?.length
? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`))
: "";
log.info(`${formatProgress(current)} app ${item.path}${scriptsInfo}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
errors.push({ path: item.path, error: msg });
log.info(`${formatProgress(current)} app ${item.path}`);
log.error(` Failed: ${msg}`);
}
}
// Persist all stale workspace dep hashes (not just filtered — deps are global, not folder-scoped)
const allStaleDeps = staleItems.filter((i) => i.type === "dependencies");
await tree.persistDepsHashes(allStaleDeps.map((d) => d.path));
const succeeded = total - errors.length;
log.info("");
log.info(`Done. Updated ${colors.bold(String(total))} item(s).`);
if (errors.length > 0) {
log.info(`Done. Updated ${colors.bold(String(succeeded))}/${total} item(s). ${colors.red(String(errors.length) + " failed")}:`);
for (const { path, error } of errors) {
log.error(` ${path}: ${error}`);
}
process.exitCode = 1;
} else {
log.info(`Done. Updated ${colors.bold(String(total))} item(s).`);
}
}
const command = new 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;
+30 -59
View File
@@ -3,13 +3,14 @@ import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
import * as log from "../../core/log.ts";
import { stringify as yamlStringify } from "yaml";
import { type BranchBinding } from "./template.ts";
import { GlobalOptions } from "../../types.ts";
import { readLockfile } from "../../utils/metadata.ts";
import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts";
import { generateRTNamespace } from "../resource-type/resource-type.ts";
import { SKILLS, SKILL_CONTENT, SCHEMAS, SCHEMA_MAPPINGS } from "../../guidance/skills.ts";
import { generateAgentsMdContent } from "../../guidance/core.ts";
import { generateCommentedTemplate } from "./template.ts";
/**
* Format a YAML schema for inclusion in skill markdown files.
@@ -42,61 +43,37 @@ export interface InitOptions {
*/
async function initAction(opts: InitOptions) {
if (await stat("wmill.yaml").catch(() => null)) {
log.error(colors.red("wmill.yaml already exists"));
log.info("wmill.yaml already exists, skipping config generation");
} else {
// Import DEFAULT_SYNC_OPTIONS from conf.ts
const { DEFAULT_SYNC_OPTIONS } = await import("../../core/conf.ts");
// Create initial config with defaults
const initialConfig = { ...DEFAULT_SYNC_OPTIONS } as any;
// Add branch structure
// Detect current git branch for template
const { isGitRepository, getCurrentGitBranch } = await import(
"../../utils/git.ts"
);
let branchName: string | undefined;
let binding: BranchBinding | undefined;
if (isGitRepository()) {
const currentBranch = getCurrentGitBranch();
if (currentBranch) {
initialConfig.gitBranches = {
[currentBranch]: { overrides: {} },
};
} else {
initialConfig.gitBranches = {};
}
} else {
initialConfig.gitBranches = {};
branchName = getCurrentGitBranch() ?? undefined;
}
initialConfig.nonDottedPaths = true;
await writeFile("wmill.yaml", yamlStringify(initialConfig), "utf-8");
log.info(colors.green("wmill.yaml created with default settings"));
// Create lock file
await readLockfile();
// Offer to bind workspace profile to current branch
if (isGitRepository()) {
// Determine workspace binding before writing the template
if (isGitRepository() && branchName) {
const activeWorkspace = await getActiveWorkspaceOrFallback(
opts as GlobalOptions
);
const currentBranch = getCurrentGitBranch();
if (activeWorkspace && currentBranch) {
// Determine binding behavior based on flags
if (activeWorkspace) {
const shouldBind = opts.bindProfile === true;
const shouldPrompt =
opts.bindProfile === undefined &&
!!process.stdin.isTTY &&
!opts.useDefault;
const shouldSkip =
opts.bindProfile != true &&
(opts.useDefault || !!!process.stdin.isTTY);
(opts.useDefault || !process.stdin.isTTY);
if (!shouldSkip) {
// Show workspace info if we're binding or prompting
if (shouldBind || shouldPrompt) {
log.info(
colors.yellow(`\nCurrent Git branch: ${colors.bold(currentBranch)}`)
colors.yellow(`\nCurrent Git branch: ${colors.bold(branchName)}`)
);
log.info(
colors.yellow(
@@ -118,37 +95,31 @@ async function initAction(opts: InitOptions) {
default: true,
})))
) {
// Update the config with workspace binding
const currentConfig = await import("../../core/conf.ts").then((m) =>
m.readConfigFile()
);
if (!currentConfig.gitBranches) {
currentConfig.gitBranches = {};
}
if (!currentConfig.gitBranches[currentBranch]) {
currentConfig.gitBranches[currentBranch] = { overrides: {} };
}
log.info(
`binding branch ${currentBranch} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}`
);
currentConfig.gitBranches[currentBranch].baseUrl =
activeWorkspace.remote;
currentConfig.gitBranches[currentBranch].workspaceId =
activeWorkspace.workspaceId;
await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8");
log.info(
colors.green(
`✓ Bound branch '${currentBranch}' to workspace '${activeWorkspace.name}'`
)
`binding branch ${branchName} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}`
);
binding = {
baseUrl: activeWorkspace.remote,
workspaceId: activeWorkspace.workspaceId,
};
}
}
}
}
await writeFile("wmill.yaml", generateCommentedTemplate(branchName, binding), "utf-8");
log.info(colors.green("wmill.yaml created with default settings"));
if (binding) {
log.info(
colors.green(
`✓ Bound branch '${branchName}' to workspace`
)
);
}
// Create lock file
await readLockfile();
// Check for backend git-sync settings unless --use-default is specified
if (!opts.useDefault) {
try {
+395
View File
@@ -0,0 +1,395 @@
/**
* Configuration option descriptor — each entry IS a JSON Schema property
* with extra metadata for template rendering and reference table display.
*
* To generate the JSON Schema: iterate entries, strip NON_SCHEMA_KEYS, done.
* Sub-fields of complex types (codebases items, gitBranches branch config)
* are defined inline in the parent's schema — no duplicate entries needed.
* The reference table auto-expands nested schemas into rows.
*
* Adding a new option:
* 1. Add an entry to CONFIG_REFERENCE with JSON Schema type fields + description
* 2. Add template rendering hints (section, commented, templateValue, etc.)
* 3. `wmill init` (YAML template), `wmill config` (table), and wmill.schema.json all update automatically
*/
export interface ConfigOption {
// --- JSON Schema fields (kept when generating schema) ---
type: string;
description: string;
enum?: string[];
items?: Record<string, any>;
properties?: Record<string, any>;
additionalProperties?: Record<string, any> | boolean;
required?: string[];
// --- Non-schema metadata (stripped when generating schema) ---
name: string;
default: string;
// --- Template rendering hints (also stripped) ---
section?: string;
sectionNote?: string;
commented?: boolean;
templateValue?: string;
example?: string;
inlineComment?: string;
groupNote?: string;
}
/** Keys to strip from ConfigOption entries when generating JSON Schema. */
const NON_SCHEMA_KEYS = new Set([
"name", "default",
"section", "sectionNote", "commented", "templateValue",
"example", "inlineComment", "groupNote",
]);
// Reusable sub-schemas for nested types
const SPECIFIC_ITEMS_SCHEMA = {
type: "object",
description: "Sync only specific items",
properties: {
variables: { type: "array", items: { type: "string" }, description: "Specific variable paths to sync" },
resources: { type: "array", items: { type: "string" }, description: "Specific resource paths to sync" },
triggers: { type: "array", items: { type: "string" }, description: "Specific trigger paths to sync" },
folders: { type: "array", items: { type: "string" }, description: "Specific folder paths to sync" },
settings: { type: "boolean", description: "Whether to sync settings" },
},
additionalProperties: false,
} as const;
const BRANCH_CONFIG_SCHEMA = {
type: "object",
properties: {
baseUrl: { type: "string", description: "Windmill instance URL for this branch" },
workspaceId: { type: "string", description: "Workspace ID to sync with for this branch" },
overrides: { type: "object", description: "Override any top-level sync option for this branch" },
promotionOverrides: { type: "object", description: "Overrides applied when using --promotion flag" },
specificItems: SPECIFIC_ITEMS_SCHEMA,
},
additionalProperties: false,
} as const;
/**
* All wmill.yaml configuration options — single source of truth.
* Each entry is a JSON Schema property with extra metadata.
*/
export const CONFIG_REFERENCE: ConfigOption[] = [
// ── Core ──────────────────────────────────────────────────────────────
{ name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts" },
{ name: "includes", type: "array", items: { type: "string" }, default: '["f/**"]', description: "Glob patterns for files to include in sync",
templateValue: '\n - "f/**"' },
{ name: "extraIncludes", type: "array", items: { type: "string" }, default: "[]", description: "Additional glob patterns merged with includes (useful in branch overrides)",
commented: true },
{ name: "excludes", type: "array", items: { type: "string" }, default: "[]", description: "Glob patterns for files to exclude from sync" },
// ── What to sync ──────────────────────────────────────────────────────
{ name: "skipVariables", type: "boolean", default: "false", description: "Skip syncing variables",
section: "What to sync", sectionNote: '"skip" options default to false (synced), "include" options default to false (not synced)' },
{ name: "skipResources", type: "boolean", default: "false", description: "Skip syncing resources" },
{ name: "skipResourceTypes", type: "boolean", default: "false", description: "Skip syncing resource types" },
{ name: "skipSecrets", type: "boolean", default: "true", description: "Skip syncing secrets (true by default for security)",
inlineComment: "true by default — secrets are not synced for security" },
{ name: "skipScripts", type: "boolean", default: "false", description: "Skip syncing scripts" },
{ name: "skipFlows", type: "boolean", default: "false", description: "Skip syncing flows" },
{ name: "skipApps", type: "boolean", default: "false", description: "Skip syncing apps" },
{ name: "skipFolders", type: "boolean", default: "false", description: "Skip syncing folders" },
{ name: "skipWorkspaceDependencies", type: "boolean", default: "false", description: "Skip syncing workspace dependencies" },
{ name: "includeSchedules", type: "boolean", default: "false", description: "Include schedules in sync",
commented: true, templateValue: "true", groupNote: "Uncomment to include these (excluded by default):" },
{ name: "includeTriggers", type: "boolean", default: "false", description: "Include triggers (http, websocket, kafka, etc.) in sync",
commented: true, templateValue: "true" },
{ name: "includeUsers", type: "boolean", default: "false", description: "Include workspace users in sync",
commented: true, templateValue: "true" },
{ name: "includeGroups", type: "boolean", default: "false", description: "Include workspace groups in sync",
commented: true, templateValue: "true" },
{ name: "includeSettings", type: "boolean", default: "false", description: "Include workspace settings in sync",
commented: true, templateValue: "true" },
{ name: "includeKey", type: "boolean", default: "false", description: "Include encryption key in sync",
commented: true, templateValue: "true" },
// ── Sync behavior ─────────────────────────────────────────────────────
{ name: "parallel", type: "integer", default: "(unset)", description: "Number of parallel operations during sync",
section: "Sync behavior", commented: true, templateValue: "4" },
{ name: "locksRequired", type: "boolean", default: "false", description: "Require lock files for all scripts",
commented: true, templateValue: "true" },
{ name: "lint", type: "boolean", default: "false", description: "Run linting before push",
commented: true, templateValue: "true" },
{ name: "plainSecrets", type: "boolean", default: "false", description: "Handle secrets as plain text (not recommended)",
commented: true },
{ name: "message", type: "string", default: "(unset)", description: "Default commit message for sync operations",
commented: true, templateValue: '"my commit message"' },
{ name: "promotion", type: "string", default: "(unset)", description: "Branch name to use promotion overrides from during sync",
commented: true, templateValue: "staging" },
{ name: "skipBranchValidation", type: "boolean", default: "false", description: "Skip validation that current git branch matches a configured branch",
commented: true },
{ name: "nonDottedPaths", type: "boolean", default: "true", description: "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app" },
// ── Codebase bundling ─────────────────────────────────────────────────
{ name: "codebases", type: "array", default: "[]", description: "Codebase bundling configurations for shared libraries",
items: {
type: "object",
properties: {
relative_path: { type: "string", description: "Path to the codebase directory" },
includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" },
excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" },
format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" },
external: { type: "array", items: { type: "string" }, description: "Dependencies to leave unbundled (externals)" },
assets: { type: "array", items: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"] }, description: "Static files to copy into the bundle" },
customBundler: { type: "string", description: "Path to a custom bundler script (replaces esbuild)" },
inject: { type: "array", items: { type: "string" }, description: "Files to inject into every entry point" },
define: { type: "object", additionalProperties: { type: "string" }, description: "Compile-time constant definitions" },
banner: { type: "object", additionalProperties: { type: "string" }, description: "Text to prepend to output files by type" },
loader: { type: "object", additionalProperties: { type: "string" }, description: "esbuild loader overrides by extension" },
},
required: ["relative_path"],
additionalProperties: false,
},
section: "Codebase bundling (shared libraries)",
sectionNote: "Bundle TypeScript/JavaScript codebases that scripts import from.\nEach entry is bundled and uploaded so scripts can import shared code.",
example: [
"# codebases:",
'# - relative_path: ./shared # path to the codebase',
'# includes: ["**/*.ts"] # files to include in bundle',
'# excludes: ["node_modules/**"] # files to exclude',
'# format: esm # bundle format: "cjs" or "esm"',
'# external: ["pg", "axios"] # dependencies to leave unbundled',
"# assets: # static files to copy into bundle",
"# - from: ./static",
"# to: ./dist",
"# # customBundler: ./build.ts # custom bundler script (replaces esbuild)",
'# # inject: ["./polyfills.ts"] # files to inject into every entry point',
"# # define: # compile-time constants",
"# # API_URL: '\"https://api.example.com\"'",
"# # banner: # text prepended to output files",
'# # js: "/* bundled by windmill */"',
"# # loader: # esbuild loader overrides",
'# # ".png": "dataurl"',
].join("\n"),
},
// ── Git branches ──────────────────────────────────────────────────────
{ name: "gitBranches", type: "object", default: "{}", description: "Map git branches to workspaces and per-branch sync overrides",
properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA },
additionalProperties: BRANCH_CONFIG_SCHEMA,
section: "Git branch / environment bindings",
sectionNote: "Map git branches to Windmill workspaces and override settings per branch.\nUse \"environments\" as an alias if you prefer environment-based terminology.",
templateValue: "\n {{BRANCH}}:\n overrides: {}",
example: [
"{{BASEURL_LINE}}",
"{{WORKSPACE_ID_LINE}}",
" # promotionOverrides: # overrides applied during --promotion",
" # skipSecrets: false",
" # specificItems: # only sync these specific items",
' # variables: ["f/my_folder/my_var"]',
' # resources: ["f/my_folder/my_res"]',
' # triggers: ["f/my_folder/my_trigger"]',
' # folders: ["my_folder"]',
" # settings: true",
"",
" # Example: staging branch bound to a different workspace",
" # staging:",
" # baseUrl: https://staging.windmill.dev",
" # workspaceId: staging-workspace",
" # overrides:",
" # skipSecrets: false",
" # includeSchedules: true",
"",
" # Items shared across ALL branches",
" # commonSpecificItems:",
' # variables: ["f/shared/api_key"]',
' # resources: ["f/shared/db_conn"]',
' # folders: ["shared"]',
].join("\n"),
},
{ name: "environments", type: "object", default: "{}", description: "Alias for gitBranches — use if you prefer environment-based terminology",
properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA },
additionalProperties: BRANCH_CONFIG_SCHEMA,
commented: true },
];
// ─── Template generator ─────────────────────────────────────────────────────
export interface BranchBinding {
baseUrl: string;
workspaceId: string;
}
/** Quote a string for use as a YAML key if it contains special characters. */
function yamlKey(s: string): string {
if (
/^[a-zA-Z0-9_/.@-]+$/.test(s) &&
!/^(true|false|yes|no|on|off|null|~)$/i.test(s) &&
!/^\d+(\.\d+)?$/.test(s)
) {
return s;
}
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
export function generateCommentedTemplate(branchName?: string, binding?: BranchBinding): string {
const branch = yamlKey(branchName ?? "main");
const lines: string[] = [
"# yaml-language-server: $schema=wmill.schema.json",
"# wmill.yaml — Windmill CLI configuration",
'# Full reference: run "wmill config"',
"",
];
for (const opt of CONFIG_REFERENCE) {
if (opt.section) {
const ruler = "-".repeat(Math.max(0, 65 - opt.section.length));
lines.push(`# --- ${opt.section} ${ruler}`);
if (opt.sectionNote) {
for (const noteLine of opt.sectionNote.split("\n")) {
lines.push(`# ${noteLine}`);
}
}
lines.push("");
}
if (opt.groupNote) {
lines.push(`# ${opt.groupNote}`);
}
const value = opt.templateValue ?? opt.default;
const resolvedValue = value.replace("{{BRANCH}}", branch);
if (opt.commented) {
lines.push(`# ${opt.description}`);
lines.push(`# ${opt.name}: ${resolvedValue}`);
} else {
lines.push(`# ${opt.description}`);
if (opt.inlineComment) {
const base = `${opt.name}: ${resolvedValue}`;
const pad = " ".repeat(Math.max(1, 32 - base.length));
lines.push(`${base}${pad}# ${opt.inlineComment}`);
} else {
lines.push(`${opt.name}: ${resolvedValue}`);
}
}
if (opt.example) {
let resolvedExample = opt.example.replace(/\{\{BRANCH\}\}/g, branch);
if (binding) {
resolvedExample = resolvedExample
.replace("{{BASEURL_LINE}}", ` baseUrl: ${binding.baseUrl}`)
.replace("{{WORKSPACE_ID_LINE}}", ` workspaceId: ${binding.workspaceId}`);
} else {
resolvedExample = resolvedExample
.replace("{{BASEURL_LINE}}", " # baseUrl: https://app.windmill.dev # Windmill instance URL for this branch")
.replace("{{WORKSPACE_ID_LINE}}", " # workspaceId: my-workspace # workspace to sync with");
}
for (const exLine of resolvedExample.split("\n")) {
lines.push(exLine);
}
}
lines.push("");
}
return lines.join("\n");
}
// ─── Reference formatters ───────────────────────────────────────────────────
/** Recursively expand a schema's properties into flat reference rows. */
function expandSchema(
prefix: string,
schema: Record<string, any>,
rows: { name: string; description: string; default: string }[]
): void {
if (schema.properties) {
for (const [key, prop] of Object.entries(schema.properties) as [string, Record<string, any>][]) {
const name = prefix ? `${prefix}.${key}` : key;
rows.push({ name, description: prop.description ?? "", default: "" });
// Recurse into nested object properties (e.g., specificItems)
if (prop.properties && prop.type === "object") {
expandSchema(name, prop, rows);
}
}
}
}
export function formatConfigReference(): string {
const nameWidth = 48;
const descWidth = 70;
const header = [
"OPTION".padEnd(nameWidth),
"DESCRIPTION".padEnd(descWidth),
"DEFAULT",
].join(" ");
const separator = "-".repeat(header.length + 10);
const allRows: { name: string; description: string; default: string }[] = [];
for (const opt of CONFIG_REFERENCE) {
allRows.push({ name: opt.name, description: opt.description, default: opt.default });
// Auto-expand array item properties (e.g., codebases[].*)
if (opt.items?.properties) {
expandSchema(`${opt.name}[]`, opt.items, allRows);
}
// Auto-expand additionalProperties (e.g., gitBranches.<branch>.*)
if (opt.additionalProperties && typeof opt.additionalProperties === "object" && opt.additionalProperties.properties) {
expandSchema(`${opt.name}.<branch>`, opt.additionalProperties as Record<string, any>, allRows);
}
// Auto-expand named properties (e.g., gitBranches.commonSpecificItems)
if (opt.properties) {
expandSchema(opt.name, opt, allRows);
}
}
const rows = allRows.map((r) =>
[r.name.padEnd(nameWidth), r.description.padEnd(descWidth), r.default].join(" ")
);
return [
"wmill.yaml — Configuration Reference",
"",
"Full documentation: https://www.windmill.dev/docs/advanced/cli",
"",
separator,
header,
separator,
...rows,
separator,
"",
'Run "wmill init" to generate a wmill.yaml with commented examples.',
].join("\n");
}
export function formatConfigReferenceJson(): string {
const clean = CONFIG_REFERENCE.map((opt) => ({
name: opt.name, type: opt.type, default: opt.default, description: opt.description,
}));
return JSON.stringify(clean, null, 2);
}
// ─── JSON Schema generator ──────────────────────────────────────────────────
/**
* Generate a JSON Schema for wmill.yaml by stripping non-schema keys from CONFIG_REFERENCE.
*/
export function generateJsonSchema(): Record<string, any> {
const properties: Record<string, any> = {};
for (const opt of CONFIG_REFERENCE) {
const entry: Record<string, any> = {};
for (const [k, v] of Object.entries(opt)) {
if (!NON_SCHEMA_KEYS.has(k) && k !== "name") {
entry[k] = v;
}
}
properties[opt.name] = entry;
}
return {
$schema: "http://json-schema.org/draft-07/schema#",
title: "wmill.yaml",
description: "Windmill CLI configuration file. Full reference: wmill config",
type: "object",
properties,
additionalProperties: false,
};
}
+37 -2
View File
@@ -219,6 +219,22 @@ export async function pickInstance(
prefix: opts.prefix ?? "custom",
};
}
// Try to use the active workspace profile's remote as a fallback
if (instances.length < 1) {
try {
const ws = await getActiveWorkspace({});
if (ws?.remote && ws?.token) {
const remote = ws.remote.endsWith("/") ? ws.remote.slice(0, -1) : ws.remote;
setClient(ws.token, remote);
return {
name: ws.name,
remote: ws.remote,
token: ws.token,
prefix: ws.name,
};
}
} catch { /* ignore */ }
}
if (!allowNew && instances.length < 1) {
throw new Error("No instance found, please add one first");
}
@@ -648,9 +664,27 @@ export async function getActiveInstance(opts: {
}
}
async function getConfig(opts: InstanceSyncOptions & { outputFile?: string }) {
async function getConfig(opts: InstanceSyncOptions & { outputFile?: string; showSecrets?: boolean }) {
await pickInstance(opts, false);
const config = await wmill.getInstanceConfig();
const config = await wmill.getInstanceConfig() as any;
// In interactive mode, mask secrets by default and prompt
const hasSecrets = config?.global_settings?.license_key || config?.global_settings?.jwt_secret;
let showSecrets = opts.showSecrets ?? false;
if (!showSecrets && hasSecrets && process.stdout.isTTY && !opts.outputFile) {
log.warn("Config contains sensitive fields (license_key, jwt_secret). They are masked by default.");
log.warn("Use --show-secrets to include them, or press Y to show them now.");
showSecrets = await Confirm.prompt({ message: "Show secrets?", default: false });
} else if (!process.stdout.isTTY || opts.outputFile) {
// Non-interactive or writing to file: always include secrets
showSecrets = true;
}
if (!showSecrets && config?.global_settings) {
if (config.global_settings.license_key) config.global_settings.license_key = "***";
if (config.global_settings.jwt_secret) config.global_settings.jwt_secret = "***";
}
const yaml = yamlStringify(config as Record<string, unknown>);
if (opts.outputFile) {
await writeFile(opts.outputFile, yaml, "utf-8");
@@ -786,6 +820,7 @@ const command = new Command()
.command("get-config")
.description("Dump the current instance config (global settings + worker configs) as YAML")
.option("-o, --output-file <file:string>", "Write YAML to a file instead of stdout")
.option("--show-secrets", "Include sensitive fields (license key, JWT secret) without prompting")
.option(
"--instance <instance:string>",
"Name of the instance, override the active instance",
+400
View File
@@ -0,0 +1,400 @@
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;
parent?: string;
isFlowStep?: 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;
// When --all or --parent is used, include flow sub-job kinds too
const showSubJobs = opts.all || opts.parent;
const defaultJobKinds = showSubJobs
? "script,flow,singlestepflow,flowscript,flowdependencies"
: "script,flow,singlestepflow";
const limit = Math.min(opts.limit ?? 30, 100);
const allJobs = await wmill.listJobs({
workspace: workspace.workspaceId,
scriptPathExact: opts.scriptPath,
createdBy: opts.createdBy,
running: opts.running,
success: successFilter,
perPage: limit,
jobKinds: opts.jobKinds ?? defaultJobKinds,
label: opts.label,
hasNullParent: showSubJobs ? undefined : true,
parentJob: opts.parent,
isFlowStep: opts.isFlowStep,
});
// API may return more than perPage — enforce limit client-side
const jobs = allJobs.slice(0, limit);
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,
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.`);
}
}
function getModuleStatusIcon(type: string, success?: boolean): string {
switch (type) {
case "Success": return colors.green("✓");
case "Failure": return colors.red("✗");
case "InProgress": return colors.blue("▶");
case "WaitingForPriorSteps": return colors.dim("○");
case "WaitingForEvents": return colors.yellow("⏳");
default: return colors.dim("·");
}
}
function formatFlowSteps(
flowStatus: any,
rawFlow: any,
) {
const modules = flowStatus?.modules ?? [];
const rawModules = rawFlow?.modules ?? [];
// Build summary map from raw_flow
const summaryMap = new Map<string, string>();
for (const mod of rawModules) {
if (mod.id && mod.summary) {
summaryMap.set(mod.id, mod.summary);
}
}
console.log(colors.bold("\nSteps:"));
for (const mod of modules) {
const icon = getModuleStatusIcon(mod.type);
const summary = summaryMap.get(mod.id) ?? "";
const label = summary ? `${mod.id}: ${summary}` : mod.id;
const jobId = mod.job ? colors.dim(mod.job) : "";
const flowJobsDuration = mod.flow_jobs_duration;
// For-loop modules: show parent line + iteration sub-lines
const flowJobs = mod.flow_jobs as string[] | undefined;
if (flowJobs && flowJobs.length > 0) {
// Total duration for the for-loop
const totalMs = flowJobsDuration?.duration_ms
? (flowJobsDuration.duration_ms as number[]).reduce((a: number, b: number) => a + b, 0)
: undefined;
const durationStr = totalMs != null ? colors.dim(formatDuration(totalMs)) : "";
console.log(` ${icon} ${label} ${durationStr}`);
const flowJobsSuccess = (mod.flow_jobs_success ?? []) as boolean[];
const durationMs = (flowJobsDuration?.duration_ms ?? []) as number[];
for (let iter = 0; iter < flowJobs.length; iter++) {
const iterSuccess = flowJobsSuccess[iter];
const iterIcon = iterSuccess === true ? colors.green("✓")
: iterSuccess === false ? colors.red("✗")
: colors.dim("·");
const iterDur = durationMs[iter] != null ? colors.dim(formatDuration(durationMs[iter])) : "";
const iterJobId = colors.dim(flowJobs[iter]);
console.log(` ${iterIcon} iteration ${iter} ${iterJobId} ${iterDur}`);
}
} else {
// Regular step
const durationStr = mod.duration_ms != null
? colors.dim(formatDuration(mod.duration_ms))
: "";
console.log(` ${icon} ${label} ${jobId} ${durationStr}`);
}
}
// Show hint for diving into step logs
const hasJobs = modules.some((m: any) => m.job);
if (hasJobs) {
console.log(colors.dim("\nUse 'wmill job logs <job-id>' for step logs"));
}
}
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);
}
// Flow: show hierarchical step status
const isFlow = j.job_kind === "flow" || j.job_kind === "flowpreview";
if (isFlow && j.flow_status) {
formatFlowSteps(j.flow_status, j.raw_flow);
}
if (j.result !== undefined) {
console.log(colors.bold("\nResult:"));
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);
// Check if this is a flow job — if so, aggregate all step logs
try {
const job = await wmill.getJob({
workspace: workspace.workspaceId,
id,
});
const j = job as any;
const jobKind = j.job_kind;
if ((jobKind === "flow" || jobKind === "flowpreview") && j.flow_status?.modules) {
const modules = j.flow_status.modules;
const rawModules = j.raw_flow?.modules ?? [];
const summaryMap = new Map<string, string>();
for (const mod of rawModules) {
if (mod.id && mod.summary) summaryMap.set(mod.id, mod.summary);
}
// Strip the "to remove ansi colors" hint that appears in each step's logs
const stripHint = (text: string) =>
text.replace(/^to remove ansi colors.*\n?/gm, "");
let hasLogs = false;
for (const mod of modules) {
const summary = summaryMap.get(mod.id) ?? "";
const label = summary ? `${mod.id}: ${summary}` : mod.id;
// For-loop modules: get logs for each iteration
const flowJobs = mod.flow_jobs as string[] | undefined;
if (flowJobs && flowJobs.length > 0) {
for (let iter = 0; iter < flowJobs.length; iter++) {
try {
const stepLogs = await wmill.getJobLogs({
workspace: workspace.workspaceId,
id: flowJobs[iter],
});
if (stepLogs) {
console.log(colors.bold.cyan(`\n====== ${label} (iteration ${iter}) ======`));
console.log(stripHint(stepLogs));
hasLogs = true;
}
} catch { /* step may not exist yet */ }
}
} else if (mod.job) {
// Regular step
try {
const stepLogs = await wmill.getJobLogs({
workspace: workspace.workspaceId,
id: mod.job,
});
if (stepLogs) {
console.log(colors.bold.cyan(`\n====== ${label} ======`));
console.log(stripHint(stepLogs));
hasLogs = true;
}
} catch { /* step may not exist yet */ }
}
}
if (!hasLogs) {
log.info("No logs available for this flow's steps.");
}
return;
}
} catch {
// If we can't get the job info, proceed with trying to get logs anyway
}
const jobLogs = await wmill.getJobLogs({
workspace: workspace.workspaceId,
id,
});
if (jobLogs == null || jobLogs === "") {
log.info("No logs available for this job.");
} else {
// Strip the hint if the API already includes it, then print it once to stderr
const stripped = jobLogs.replace(/^to remove ansi colors.*\n?/gm, "");
console.error("to remove ansi colors, use: | sed 's/\\x1B\\[[0-9;]\\{1,\\}[A-Za-z]//g'");
console.log(stripped);
}
}
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")
.option("--parent <parent:string>", "Filter by parent job ID (show sub-jobs of a specific flow)")
.option("--is-flow-step", "Show only flow step jobs");
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. For flows: shows step tree with sub-job IDs")
.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. For flows: aggregates all step 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;
+46 -3
View File
@@ -625,7 +625,13 @@ export async function runLint(
throw new Error(`Path is not a directory: ${targetDirectory}`);
}
const ignore = await ignoreF(mergedOpts);
// When the user specifies a subdirectory (that doesn't contain wmill.yaml),
// skip include/exclude filters since they're relative to the project root.
const isSubdirectory = explicitTargetDirectory &&
!(await stat(path.join(targetDirectory, "wmill.yaml")).catch(() => null));
const ignore = isSubdirectory
? (_p: string, _isDir: boolean) => false
: await ignoreF(mergedOpts);
const root = await FSFSElement(targetDirectory, [], false);
const validator = new WindmillYamlValidator();
@@ -640,9 +646,10 @@ export async function runLint(
if (entry.isDirectory || entry.ignored) {
continue;
}
scannedFiles += 1;
const normalizedPath = normalizePath(entry.path);
scannedFiles += 1;
if (!YAML_FILE_REGEX.test(normalizedPath)) {
continue;
}
@@ -742,7 +749,11 @@ export function printReport(report: LintReport, jsonOutput: boolean) {
}
}
async function lint(opts: LintOptions, directory?: string) {
async function lint(opts: LintOptions & { watch?: boolean }, directory?: string) {
if (opts.watch) {
await lintWatch(opts, directory);
return;
}
try {
const report = await runLint(opts, directory);
printReport(report, !!opts.json);
@@ -770,6 +781,37 @@ async function lint(opts: LintOptions, directory?: string) {
}
}
async function lintWatch(opts: LintOptions, directory?: string) {
const { watch } = await import("node:fs");
const targetDir = directory ? path.resolve(process.cwd(), directory) : process.cwd();
log.info(colors.blue(`Watching ${targetDir} for changes... (Ctrl+C to stop)`));
async function runAndReport() {
try {
const report = await runLint(opts, directory);
// Clear screen for readability
process.stdout.write("\x1Bc");
log.info(colors.gray(`[${new Date().toLocaleTimeString()}] Lint results:\n`));
printReport(report, false);
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
}
}
await runAndReport();
let debounce: ReturnType<typeof setTimeout> | null = null;
watch(targetDir, { recursive: true }, (_event, filename) => {
if (!filename || !filename.toString().endsWith(".yaml") && !filename.toString().endsWith(".yml")) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(runAndReport, 300);
});
// Keep the process alive
await new Promise(() => {});
}
const command = new Command()
.description(
"Validate Windmill flow, schedule, and trigger YAML files in a directory",
@@ -781,6 +823,7 @@ const command = new Command()
"--locks-required",
"Fail if scripts or flow inline scripts that need locks have no locks",
)
.option("-w, --watch", "Watch for file changes and re-lint automatically")
.action(lint as any);
export default command;
@@ -88,6 +88,7 @@ async function push(opts: PushOptions, filePath: string, name: string) {
}
async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean }) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const res = await wmill.listResourceType({
@@ -96,6 +97,10 @@ async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean })
if (opts.json) {
console.log(JSON.stringify(res));
} else if (res.length === 0) {
log.info("No custom resource types found in this workspace.");
log.info("Built-in types like 'postgresql', 'slack', 'mysql', etc. are available from the Windmill Hub.");
return;
} else if (opts.schema) {
new Table()
.header(["Workspace", "Name", "Schema"])
+4 -1
View File
@@ -1,4 +1,4 @@
import { stat, writeFile, readdir, readFile } from "node:fs/promises";
import { mkdir, stat, writeFile, readdir, readFile } from "node:fs/promises";
import { stringify as yamlStringify } from "yaml";
import nodePath from "node:path";
@@ -156,6 +156,7 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) {
}
async function list(opts: GlobalOptions & { json?: boolean }) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
let page = 0;
@@ -203,6 +204,7 @@ async function newResource(opts: GlobalOptions, path: string) {
resource_type: "",
description: "",
};
await mkdir(nodePath.dirname(filePath), { recursive: true });
await writeFile(filePath, yamlStringify(template as Record<string, any>), {
flag: "wx",
encoding: "utf-8",
@@ -211,6 +213,7 @@ async function newResource(opts: GlobalOptions, path: string) {
}
async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const r = await wmill.getResource({
+42 -3
View File
@@ -1,4 +1,5 @@
import { stat, writeFile } from "node:fs/promises";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { stringify as yamlStringify } from "yaml";
import { Command } from "@cliffy/command";
@@ -8,6 +9,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 {
@@ -29,6 +31,7 @@ export interface ScheduleFile {
}
async function list(opts: GlobalOptions & { json?: boolean }) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
@@ -60,7 +63,7 @@ async function newSchedule(opts: GlobalOptions, path: string) {
if (e.message?.startsWith("File already exists")) throw e;
}
const template: ScheduleFile = {
schedule: "0 */6 * * *",
schedule: "0 0 */6 * * *",
on_failure: "",
script_path: "",
args: {},
@@ -68,6 +71,7 @@ async function newSchedule(opts: GlobalOptions, path: string) {
is_flow: false,
enabled: false,
};
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, yamlStringify(template as Record<string, any>), {
flag: "wx",
encoding: "utf-8",
@@ -76,6 +80,7 @@ async function newSchedule(opts: GlobalOptions, path: string) {
}
async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const s = await wmill.getSchedule({
@@ -162,6 +167,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);
@@ -205,6 +238,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;
+164 -24
View File
@@ -1,7 +1,7 @@
import { GlobalOptions } from "../../types.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
import { readFile, writeFile, stat } from "node:fs/promises";
import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
import { Buffer } from "node:buffer";
import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
@@ -23,10 +23,13 @@ import {
import { Workspace } from "../workspace/workspace.ts";
import {
checkifMetadataUptodate,
generateScriptMetadataInternal,
getRawWorkspaceDependencies,
parseMetadataFile,
readLockfile,
} from "../../utils/metadata.ts";
import { generateHash, validateRequiredArgs } from "../../utils/utils.ts";
import {
WorkspaceDependenciesLanguage,
ScriptLanguage,
@@ -101,7 +104,7 @@ export function isFlowInlineScriptPath(filePath: string): boolean {
return isFlowInlineScriptPathInternal(filePath);
}
type PushOptions = GlobalOptions;
type PushOptions = GlobalOptions & { message?: string };
async function push(opts: PushOptions, filePath: string) {
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
@@ -122,13 +125,35 @@ async function push(opts: PushOptions, filePath: string) {
}
await requireLogin(opts);
// Warn about metadata state before pushing
try {
const content = await readFile(filePath, "utf-8");
const remotePath = removeExtensionToPath(filePath).replaceAll(SEP, "/");
const contentHash = await generateHash(content + remotePath);
const conf = await readLockfile();
const hasLockEntry = conf.locks && (conf.locks[remotePath] !== undefined || conf.locks[`${remotePath}.ts`] !== undefined);
if (!hasLockEntry) {
log.warn(colors.yellow(
`No metadata generated yet for ${filePath}. Run 'wmill generate-metadata' to generate schema and lock.`
));
} else if (!(await checkifMetadataUptodate(remotePath, contentHash, conf))) {
log.warn(colors.yellow(
`Metadata for ${filePath} appears stale (content changed since last 'wmill generate-metadata').\n` +
`The schema and lock may not match the current code. Consider running 'wmill generate-metadata' first.`
));
}
} catch {
// Don't block push if check fails
}
const codebases = await listSyncCodebases(opts as SyncOptions);
await handleFile(
filePath,
workspace,
[],
undefined,
opts.message,
opts,
await getRawWorkspaceDependencies(true),
codebases
@@ -494,6 +519,7 @@ export async function handleFile(
const body = {
...requestBodyCommon,
parent_hash: remote.hash,
auto_parent: true,
};
const execTime = await createScript(
bundleContent,
@@ -857,6 +883,7 @@ async function list(
json?: boolean;
}
) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
@@ -919,41 +946,93 @@ async function run(
},
path: string
) {
if (opts.silent) {
log.setSilent(true);
}
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const input = opts.data ? await resolve(opts.data) : {};
const id = await wmill.runScriptByPath({
workspace: workspace.workspaceId,
path,
requestBody: input,
});
// Validate required args against schema when no data provided
if (!opts.data) {
try {
const script = await wmill.getScriptByPath({
workspace: workspace.workspaceId,
path,
});
validateRequiredArgs(script.schema as Record<string, unknown>);
} catch (e: any) {
if (e.message?.startsWith("Missing required")) throw e;
log.warn(`Could not fetch schema to validate args: ${e.message}`);
}
}
let id: string;
try {
id = await wmill.runScriptByPath({
workspace: workspace.workspaceId,
path,
requestBody: input,
});
} catch (e: any) {
if (e?.status === 404) {
// Script might exist but have a lock/deployment error — check before giving up
try {
const script = await wmill.getScriptByPath({
workspace: workspace.workspaceId,
path,
});
if (script.lock_error_logs) {
throw new Error(
`Script '${path}' has a deployment error and cannot be run:\n${script.lock_error_logs}`
);
}
} catch (lookupErr: any) {
if (lookupErr?.message?.includes("deployment error")) throw lookupErr;
// Re-throw non-404 lookup errors (e.g. auth/network issues)
if (lookupErr?.status && lookupErr.status !== 404) throw lookupErr;
}
throw new Error(
`Script '${path}' not found. Run 'wmill script list' to see available scripts.`
);
}
throw e;
}
if (!opts.silent) {
await track_job(workspace.workspaceId, id);
}
while (true) {
const MAX_RETRIES = 600; // ~60 seconds at 100ms intervals
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const result =
(
await wmill.getCompletedJob({
workspace: workspace.workspaceId,
id,
})
).result ?? {};
const completedJob = await wmill.getCompletedJob({
workspace: workspace.workspaceId,
id,
});
if (completedJob.success === false) {
process.exitCode = 1;
}
const result = completedJob.result ?? {};
if (opts.silent) {
console.log(result);
console.log(JSON.stringify(result));
} else {
log.info(JSON.stringify(result, null, 2));
}
break;
} catch {
retries++;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (retries >= MAX_RETRIES) {
throw new Error(`Timed out waiting for job ${id} to complete`);
}
}
export async function track_job(workspace: string, id: string) {
@@ -1050,6 +1129,7 @@ async function show(opts: GlobalOptions, path: string) {
}
async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const s = await wmill.getScriptByPath({
@@ -1069,24 +1149,33 @@ async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
}
}
const languageAliases: Record<string, ScriptLanguage> = {
python: "python3",
};
async function bootstrap(
opts: GlobalOptions & { summary: string; description: string },
scriptPath: string,
language: ScriptLanguage
language: ScriptLanguage | string
) {
if (!validatePath(scriptPath)) {
return;
}
const scriptInitialCode = scriptBootstrapCode[language];
const resolvedLanguage = (languageAliases[language] ?? language) as ScriptLanguage;
const scriptInitialCode = scriptBootstrapCode[resolvedLanguage];
if (scriptInitialCode === undefined) {
throw new Error("Language unknown");
const validLanguages = Object.keys(scriptBootstrapCode).sort().join(", ");
throw new Error(
`Unknown language '${language}'. Valid languages: ${validLanguages}`
);
}
const config = await readConfigFile();
const extension = filePathExtensionFromContentType(
language,
resolvedLanguage,
config.defaultTs
);
const scriptCodeFileFullPath = scriptPath + extension;
@@ -1118,6 +1207,9 @@ async function bootstrap(
yamlOptions
);
const parentDir = path.dirname(scriptCodeFileFullPath);
await mkdir(parentDir, { recursive: true });
await writeFile(scriptCodeFileFullPath, scriptInitialCode, {
flag: 'wx', encoding: 'utf-8',
});
@@ -1252,6 +1344,9 @@ async function preview(
} & SyncOptions,
filePath: string
) {
if (opts.silent) {
log.setSilent(true);
}
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
@@ -1455,13 +1550,50 @@ 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", "Created At", "Deployment Message"])
.padding(2)
.border(true)
.body(
versions.map((v, i) => [
String(versions.length - i),
v.script_hash,
v.created_at ? new Date(v.created_at).toLocaleString() : "-",
v.deployment_msg ?? "-",
])
)
.render();
}
}
const command = new Command()
.description("script related commands")
.option("--show-archived", "Enable archived scripts in output")
.option("--show-archived", "Show archived scripts instead of active ones")
.option("--json", "Output as JSON (for piping to jq)")
.action(list as any)
.command("list", "list all scripts")
.option("--show-archived", "Enable archived scripts in output")
.option("--show-archived", "Show archived scripts instead of active ones")
.option("--json", "Output as JSON (for piping to jq)")
.action(list as any)
.command(
@@ -1469,6 +1601,7 @@ const command = new Command()
"push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)"
)
.arguments("<path:file>")
.option("--message <message:string>", "Deployment message")
.action(push as any)
.command("get", "get a script's details")
.arguments("<path:file>")
@@ -1529,6 +1662,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;
+101 -25
View File
@@ -3,9 +3,72 @@ import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import * as log from "../../core/log.ts";
import JSZip from "jszip";
import { extract } from "tar-stream";
import { Readable } from "node:stream";
import { Workspace } from "../workspace/workspace.ts";
import { getHeaders } from "../../utils/utils.ts";
/**
* Adapter that wraps tar entries in a JSZip-compatible interface
* so ZipFSElement in sync.ts can consume it without changes.
*/
class TarAsZip {
files: Record<string, { dir: boolean; name: string; async(type: "text"): Promise<string> }> = {};
constructor(entries: Map<string, { content: string; isDir: boolean }>) {
for (const [name, entry] of entries) {
const content = entry.content;
this.files[name] = {
dir: entry.isDir,
name,
async(_type: "text") {
return content;
},
};
}
}
/** Return a filtered view containing only entries under the given prefix, with relative paths. */
folder(prefix: string): TarAsZip | null {
const normalized = prefix.endsWith("/") ? prefix : prefix + "/";
const sub = new TarAsZip(new Map());
for (const [name, file] of Object.entries(this.files)) {
if (name.startsWith(normalized)) {
const relative = name.slice(normalized.length);
if (relative) {
sub.files[relative] = { ...file, name: relative };
}
}
}
return Object.keys(sub.files).length > 0 ? sub : null;
}
}
async function parseTarResponse(response: Response): Promise<TarAsZip> {
const buffer = Buffer.from(await response.arrayBuffer());
const entries = new Map<string, { content: string; isDir: boolean }>();
const ex = extract();
return new Promise((resolve, reject) => {
ex.on("entry", (header, stream, next) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () => {
entries.set(header.name, {
content: Buffer.concat(chunks).toString("utf-8"),
isDir: header.type === "directory",
});
next();
});
stream.on("error", reject);
stream.resume();
});
ex.on("finish", () => resolve(new TarAsZip(entries)));
ex.on("error", reject);
Readable.from(buffer).pipe(ex);
});
}
export async function downloadZip(
workspace: Workspace,
plainSecrets: boolean | undefined,
@@ -21,7 +84,7 @@ export async function downloadZip(
includeKey?: boolean,
skipWorkspaceDependencies?: boolean,
defaultTs?: "bun" | "deno"
): Promise<JSZip | undefined> {
): Promise<JSZip | TarAsZip | undefined> {
const requestHeaders = new Headers();
requestHeaders.set("Authorization", "Bearer " + workspace.token);
requestHeaders.set("Content-Type", "application/octet-stream");
@@ -34,38 +97,51 @@ export async function downloadZip(
}
const includeWorkspaceDependenciesValue = !(skipWorkspaceDependencies ?? false);
const url = workspace.remote +
"api/w/" +
workspace.workspaceId +
`/workspaces/tarball?archive_type=zip&plain_secret=${plainSecrets ?? false
const baseParams = `&plain_secret=${plainSecrets ?? false
}&skip_variables=${skipVariables ?? false}&skip_resources=${skipResources ?? false
}&skip_secrets=${skipSecrets ?? false}&include_schedules=${includeSchedules ?? false
}&include_triggers=${includeTriggers ?? false}&include_users=${includeUsers ?? false
}&include_groups=${includeGroups ?? false}&include_settings=${includeSettings ?? false
}&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2`;
const zipResponse = await fetch(url, {
headers: requestHeaders,
method: "GET",
}
);
const baseUrl = workspace.remote + "api/w/" + workspace.workspaceId + "/workspaces/tarball?";
if (!zipResponse.ok) {
const body = await zipResponse.text();
if (zipResponse.status === 404 || body.includes("no rows returned")) {
log.info(colors.red(`Workspace '${workspace.workspaceId}' not found on ${workspace.remote}. Please check your --workspace and try again.`));
} else {
log.info(colors.red(`Failed to request tarball from API: ${zipResponse.status} ${zipResponse.statusText}`));
if (body) {
log.info(colors.red(body));
}
}
return process.exit(1);
} else {
log.debug(`Downloaded zip/tarball successfully`);
// Try zip first (standard format), fall back to tar if zip is not supported
const zipUrl = baseUrl + "archive_type=zip" + baseParams;
const zipResponse = await fetch(zipUrl, { headers: requestHeaders, method: "GET" });
if (zipResponse.ok) {
log.debug("Downloaded zip archive successfully");
const blob = await zipResponse.blob();
return await JSZip.loadAsync((await blob.arrayBuffer()) as any);
}
const blob = await zipResponse.blob();
return await JSZip.loadAsync((await blob.arrayBuffer()) as any);
const body = await zipResponse.text();
// If zip format is not supported (backend compiled without zip feature), try tar
if (zipResponse.status === 400 && body.includes("Invalid Archive Type")) {
log.debug("Zip archive not supported by backend, falling back to tar");
const tarUrl = baseUrl + "archive_type=tar" + baseParams;
const tarResponse = await fetch(tarUrl, { headers: requestHeaders, method: "GET" });
if (tarResponse.ok) {
log.debug("Downloaded tar archive successfully");
return await parseTarResponse(tarResponse);
}
const tarBody = await tarResponse.text();
log.info(colors.red(`Failed to request tarball from API: ${tarResponse.status} ${tarResponse.statusText}`));
if (tarBody) log.info(colors.red(tarBody));
return process.exit(1);
}
if (zipResponse.status === 404 || body.includes("no rows returned")) {
log.info(colors.red(`Workspace '${workspace.workspaceId}' not found on ${workspace.remote}. Please check your --workspace and try again.`));
} else {
log.info(colors.red(`Failed to request tarball from API: ${zipResponse.status} ${zipResponse.statusText}`));
if (body) log.info(colors.red(body));
}
return process.exit(1);
}
function stub(_opts: GlobalOptions & { override: boolean }, _dir: string) {
+176 -28
View File
@@ -76,7 +76,7 @@ import {
newRawAppPathAssigner,
PathAssigner,
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { generateFlowLockInternal } from "../flow/flow_metadata.ts";
import { isExecutionModeAnonymous } from "../app/app.ts";
import {
@@ -93,6 +93,8 @@ import {
isAppMetadataFile,
isRawAppMetadataFile,
isRawAppFolderMetadataFile,
isAppFolderMetadataFile,
isFlowFolderMetadataFile,
getDeleteSuffix,
transformJsonPathToDir,
getFolderSuffix,
@@ -636,9 +638,16 @@ function ZipFSElement(
let inlineScripts;
try {
const assigner = newPathAssigner(defaultTs, { skipInlineScriptSuffix: getNonDottedPaths() });
inlineScripts = extractInlineScriptsForFlows(
// Preserve original !inline filenames from the flow to avoid phantom renames
const inlineMapping = extractCurrentMapping(
flow.value.modules as any,
{},
flow.value.failure_module,
flow.value.preprocessor_module,
);
inlineScripts = extractInlineScriptsForFlows(
flow.value.modules as any,
inlineMapping,
SEP,
defaultTs,
assigner,
@@ -647,7 +656,7 @@ function ZipFSElement(
if (flow.value.failure_module) {
inlineScripts.push(...extractInlineScriptsForFlows(
[flow.value.failure_module],
{},
inlineMapping,
SEP,
defaultTs,
assigner,
@@ -657,7 +666,7 @@ function ZipFSElement(
if (flow.value.preprocessor_module) {
inlineScripts.push(...extractInlineScriptsForFlows(
[flow.value.preprocessor_module],
{},
inlineMapping,
SEP,
defaultTs,
assigner,
@@ -1516,6 +1525,10 @@ async function compareDynFSElement(
continue;
}
if (k.startsWith("dependencies/")) {
if (!workspaceDependenciesPathToLanguageAndFilename(k)) {
log.warn(`Skipping unrecognized workspace dependencies file: ${k}`);
continue;
}
log.info(`Adding workspace dependencies file: ${k}`);
}
changes.push({ name: "added", path: k, content: v });
@@ -1985,9 +1998,15 @@ export async function pull(
opts: GlobalOptions &
SyncOptions & { repository?: string; promotion?: string; branch?: string },
) {
if ((opts as any).jsonOutput) log.setSilent(true);
const originalCliOpts = { ...opts };
opts = await mergeConfigWithConfigFile(opts);
// --include-secrets overrides skipSecrets from wmill.yaml
if ((originalCliOpts as any).includeSecrets) {
opts.skipSecrets = false;
}
// Validate branch configuration early (skipped when --branch is used)
try {
await validateBranchConfiguration(opts, opts.branch);
@@ -2476,12 +2495,18 @@ function removeSuffix(str: string, suffix: string) {
export async function push(
opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string },
) {
if ((opts as any).jsonOutput) log.setSilent(true);
// Save original CLI options before merging with config file
const originalCliOpts = { ...opts };
// Load configuration from wmill.yaml and merge with CLI options
opts = await mergeConfigWithConfigFile(opts);
// --include-secrets overrides skipSecrets from wmill.yaml
if ((originalCliOpts as any).includeSecrets) {
opts.skipSecrets = false;
}
// Validate branch configuration early (skipped when --branch is used)
try {
await validateBranchConfiguration(opts, opts.branch);
@@ -2615,6 +2640,7 @@ export async function push(
const tracker: ChangeTracker = await buildTracker(changes);
const autoRegenerate = !!(opts as any).autoMetadata;
const staleScripts: string[] = [];
const staleFlows: string[] = [];
const staleApps: string[] = [];
@@ -2624,7 +2650,7 @@ export async function push(
change,
workspace,
opts,
true,
!autoRegenerate, // dryRun=false when --auto is set
true,
rawWorkspaceDependencies,
codebases,
@@ -2637,11 +2663,19 @@ export async function push(
if (staleScripts.length > 0) {
log.info("");
log.warn(
"Stale scripts metadata found, you may want to update them using 'wmill script generate-metadata' before pushing:",
);
if (autoRegenerate) {
log.info("Auto-regenerated metadata for stale scripts:");
} else {
log.warn(
"Stale scripts metadata found, you may want to update them using 'wmill script generate-metadata' before pushing:",
);
}
for (const stale of staleScripts) {
log.warn(stale);
if (autoRegenerate) {
log.info(` ${stale}`);
} else {
log.warn(stale);
}
}
log.info("");
@@ -2650,7 +2684,7 @@ export async function push(
for (const change of tracker.flows) {
const stale = await generateFlowLockInternal(
change,
true,
!autoRegenerate, // dryRun=false when --auto is set
workspace,
opts,
false,
@@ -2662,11 +2696,19 @@ export async function push(
}
if (staleFlows.length > 0) {
log.warn(
"Stale flows locks found, you may want to update them using 'wmill flow generate-locks' before pushing:",
);
if (autoRegenerate) {
log.info("Auto-regenerated locks for stale flows:");
} else {
log.warn(
"Stale flows locks found, you may want to update them using 'wmill flow generate-locks' before pushing:",
);
}
for (const stale of staleFlows) {
log.warn(stale);
if (autoRegenerate) {
log.info(` ${stale}`);
} else {
log.warn(stale);
}
}
log.info("");
}
@@ -2675,7 +2717,7 @@ export async function push(
const stale = await generateAppLocksInternal(
change,
false,
true,
!autoRegenerate,
workspace,
opts,
true,
@@ -2690,7 +2732,7 @@ export async function push(
const stale = await generateAppLocksInternal(
change,
true,
true,
!autoRegenerate,
workspace,
opts,
true,
@@ -2702,15 +2744,46 @@ export async function push(
}
if (staleApps.length > 0) {
log.warn(
"Stale apps locks found, you may want to update them using 'wmill app generate-locks' before pushing:",
);
if (autoRegenerate) {
log.info("Auto-regenerated locks for stale apps:");
} else {
log.warn(
"Stale apps locks found, you may want to update them using 'wmill app generate-locks' before pushing:",
);
}
for (const stale of staleApps) {
log.warn(stale);
if (autoRegenerate) {
log.info(` ${stale}`);
} else {
log.warn(stale);
}
}
log.info("");
}
// Warn about local files for skipped types. Walks the in-memory DynFSElement tree
// (not a fresh disk scan), but does re-traverse it. Acceptable cost for a one-time check.
{
const skippedWarnings: string[] = [];
let scheduleCount = 0;
let triggerCount = 0;
for await (const entry of readDirRecursiveWithIgnore(() => false, local)) {
if (entry.isDirectory) continue;
if (!opts.includeSchedules && entry.path.endsWith(".schedule.yaml")) scheduleCount++;
if (!opts.includeTriggers && entry.path.endsWith("_trigger.yaml")) triggerCount++;
}
if (scheduleCount > 0) {
skippedWarnings.push(`Skipping ${scheduleCount} schedule file(s). Use --include-schedules or set includeSchedules: true in wmill.yaml`);
}
if (triggerCount > 0) {
skippedWarnings.push(`Skipping ${triggerCount} trigger file(s). Use --include-triggers or set includeTriggers: true in wmill.yaml`);
}
for (const warning of skippedWarnings) {
log.warn(warning);
}
if (skippedWarnings.length > 0) log.info("");
}
await fetchRemoteVersion(workspace);
log.info(
@@ -3160,16 +3233,88 @@ export async function push(
});
break;
case "flow":
await wmill.deleteFlowByPath({
workspace: workspaceId,
path: removeSuffix(target, getDeleteSuffix("flow", "json")),
});
if (isFlowFolderMetadataFile(target)) {
// Metadata file deleted — delete the entire flow
await wmill.deleteFlowByPath({
workspace: workspaceId,
path: removeSuffix(target, getDeleteSuffix("flow", "json")),
});
} else {
// Inline script file deleted within flow folder
const flowFolder = extractFolderPath(target, "flow");
let flowFolderExists = false;
if (flowFolder) {
try {
await stat(flowFolder);
flowFolderExists = true;
} catch {
// folder doesn't exist
}
}
if (flowFolderExists) {
// Re-push the entire flow so the backend gets the updated definition
await pushObj(
workspaceId,
target,
undefined,
undefined,
opts.plainSecrets ?? false,
alreadySynced,
opts.message,
);
} else {
// Flow folder doesn't exist locally — delete on server
const remotePath = extractResourceName(target, "flow");
if (remotePath) {
await wmill.deleteFlowByPath({
workspace: workspaceId,
path: remotePath,
});
}
}
}
break;
case "app":
await wmill.deleteApp({
workspace: workspaceId,
path: removeSuffix(target, getDeleteSuffix("app", "json")),
});
if (isAppFolderMetadataFile(target)) {
// Metadata file deleted — delete the entire app
await wmill.deleteApp({
workspace: workspaceId,
path: removeSuffix(target, getDeleteSuffix("app", "json")),
});
} else {
// Inline script file deleted within app folder
const appFolder = extractFolderPath(target, "app");
let appFolderExists = false;
if (appFolder) {
try {
await stat(appFolder);
appFolderExists = true;
} catch {
// folder doesn't exist
}
}
if (appFolderExists) {
// Re-push the entire app so the backend gets the updated definition
await pushObj(
workspaceId,
target,
undefined,
undefined,
opts.plainSecrets ?? false,
alreadySynced,
opts.message,
);
} else {
// App folder doesn't exist locally — delete on server
const remotePath = extractResourceName(target, "app");
if (remotePath) {
await wmill.deleteApp({
workspace: workspaceId,
path: remotePath,
});
}
}
}
break;
case "raw_app":
if (isRawAppFolderMetadataFile(target)) {
@@ -3448,6 +3593,7 @@ const command = new Command()
.option("--json", "Use JSON instead of YAML")
.option("--skip-variables", "Skip syncing variables (including secrets)")
.option("--skip-secrets", "Skip syncing only secrets variables")
.option("--include-secrets", "Include secrets in sync (overrides skipSecrets in wmill.yaml)")
.option("--skip-resources", "Skip syncing resources")
.option("--skip-resource-types", "Skip syncing resource types")
.option("--skip-scripts", "Skip syncing scripts")
@@ -3503,6 +3649,7 @@ const command = new Command()
.option("--json", "Use JSON instead of YAML")
.option("--skip-variables", "Skip syncing variables (including secrets)")
.option("--skip-secrets", "Skip syncing only secrets variables")
.option("--include-secrets", "Include secrets in sync (overrides skipSecrets in wmill.yaml)")
.option("--skip-resources", "Skip syncing resources")
.option("--skip-resource-types", "Skip syncing resource types")
.option("--skip-scripts", "Skip syncing scripts")
@@ -3552,6 +3699,7 @@ const command = new Command()
"--locks-required",
"Fail if scripts or flow inline scripts that need locks have no locks",
)
.option("--auto-metadata", "Automatically regenerate stale metadata (locks and schemas) before pushing")
.action(push 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;
+50 -15
View File
@@ -1,4 +1,5 @@
import { stat, writeFile } from "node:fs/promises";
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";
@@ -231,6 +232,7 @@ export async function pushNativeTrigger(
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 {
@@ -243,6 +245,7 @@ export async function pushNativeTrigger(
script_path: localTrigger.script_path,
is_flow: localTrigger.is_flow,
service_config: localTrigger.service_config,
summary: localTrigger.summary,
};
if (remoteTrigger) {
@@ -251,11 +254,13 @@ export async function pushNativeTrigger(
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)) {
@@ -304,11 +309,20 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
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: {
@@ -317,6 +331,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
kafka_resource_path: "",
group_id: "",
topics: [],
filters: [],
enabled: false,
},
nats: {
@@ -324,6 +339,7 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
is_flow: false,
nats_resource_path: "",
subjects: [],
use_jetstream: false,
enabled: false,
},
postgres: {
@@ -338,28 +354,31 @@ const triggerTemplates: Record<TriggerType, Record<string, any>> = {
script_path: "",
is_flow: false,
mqtt_resource_path: "",
topics: [],
subscribe_qos: 0,
subscribe_topics: [],
enabled: false,
},
sqs: {
script_path: "",
is_flow: false,
sqs_resource_path: "",
queue_url: "",
aws_resource_path: "",
aws_auth_resource_type: "credentials",
enabled: false,
},
gcp: {
script_path: "",
is_flow: false,
gcp_resource_path: "",
subscription_id: "",
topic_id: "",
subscription_id: "",
delivery_type: "pull",
subscription_mode: "create_update",
enabled: false,
},
email: {
script_path: "",
is_flow: false,
local_part: "",
enabled: false,
},
};
@@ -383,6 +402,7 @@ async function newTrigger(opts: GlobalOptions & { kind: string }, path: string)
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",
@@ -390,7 +410,29 @@ async function newTrigger(opts: GlobalOptions & { kind: string }, path: string)
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);
@@ -402,11 +444,7 @@ async function get(opts: GlobalOptions & { json?: boolean; kind?: string }, path
if (opts.json) {
console.log(JSON.stringify(trigger));
} else {
console.log(colors.bold("Path:") + " " + (trigger as any).path);
console.log(colors.bold("Kind:") + " " + opts.kind);
console.log(colors.bold("Enabled:") + " " + ((trigger as any).enabled ?? "-"));
console.log(colors.bold("Script Path:") + " " + ((trigger as any).script_path ?? ""));
console.log(colors.bold("Is Flow:") + " " + ((trigger as any).is_flow ? "true" : "false"));
printTriggerDetails(trigger as any, opts.kind);
}
return;
}
@@ -431,11 +469,7 @@ async function get(opts: GlobalOptions & { json?: boolean; kind?: string }, path
if (opts.json) {
console.log(JSON.stringify(trigger));
} else {
console.log(colors.bold("Path:") + " " + trigger.path);
console.log(colors.bold("Kind:") + " " + kind);
console.log(colors.bold("Enabled:") + " " + (trigger.enabled ?? "-"));
console.log(colors.bold("Script Path:") + " " + (trigger.script_path ?? ""));
console.log(colors.bold("Is Flow:") + " " + (trigger.is_flow ? "true" : "false"));
printTriggerDetails(trigger, kind);
}
return;
}
@@ -457,6 +491,7 @@ async function listOrEmpty<T>(fn: () => Promise<T[]>): Promise<T[]> {
}
async function list(opts: GlobalOptions & { json?: boolean }) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
+1 -1
View File
@@ -530,7 +530,7 @@ const command = new Command()
.command("remove", "Delete a user")
.arguments("<email:string>")
.action(remove as any)
.command("create-token")
.command("create-token", "Create a new API token for the authenticated user")
.option(
"--email <email:string>",
"Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.",
+7 -3
View File
@@ -1,4 +1,5 @@
import { stat, writeFile } from "node:fs/promises";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { stringify as yamlStringify } from "yaml";
import { requireLogin } from "../../core/auth.ts";
@@ -20,6 +21,7 @@ import * as wmill from "../../../gen/services.gen.ts";
import { ListableVariable } from "../../../gen/types.gen.ts";
async function list(opts: GlobalOptions & { json?: boolean }) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
@@ -62,6 +64,7 @@ async function newVariable(opts: GlobalOptions, path: string) {
is_secret: false,
description: "",
};
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, yamlStringify(template as Record<string, any>), {
flag: "wx",
encoding: "utf-8",
@@ -70,6 +73,7 @@ async function newVariable(opts: GlobalOptions, path: string) {
}
async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
if (opts.json) log.setSilent(true);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const v = await wmill.getVariable({
@@ -214,10 +218,10 @@ async function add(
undefined,
{
value,
is_secret: !opts.public && !opts.plainSecrets,
is_secret: !opts.public,
description: "",
},
opts.plainSecrets ?? false
true // value from CLI is always plaintext — tell API not to treat it as pre-encrypted
);
log.info(colors.bold.underline.green(`Variable ${remotePath} pushed`));
}
+53 -32
View File
@@ -129,10 +129,16 @@ async function createWorkspaceFork(
const newBranchName = `${WM_FORK_PREFIX}/${clonedBranchName}/${workspaceId}`
log.info(`Created forked workspace ${trueWorkspaceId}. To start contributing to your fork, create and push edits to the branch \`${newBranchName}\` by using the command:
\t`+colors.white(`git checkout -b ${newBranchName}`) + `
When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from.`);
When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from.
To merge changes back to the parent workspace, you can:
- Use the Merge UI from the forked workspace home page
- Deploy individual items via the Deploy to staging/prod UI
- Use git: ` + colors.white(`git checkout ${clonedBranchName} && git merge ${newBranchName} && wmill sync push`) + `
See: https://www.windmill.dev/docs/advanced/workspace_forks`);
}
async function deleteWorkspaceFork(
@@ -141,54 +147,69 @@ async function deleteWorkspaceFork(
},
name: string,
) {
let forkWorkspaceId: string;
let token: string;
let remote: string;
let hasLocalProfile = false;
// Try local profile first (existing behavior)
const orgWorkspaces = await allWorkspaces(opts.configDir);
const idxOf = orgWorkspaces.findIndex((x) => x.name === name) ;
if (idxOf === -1) {
log.info(
colors.red.bold(`! Workspace profile ${name} does not exist locally`)
);
log.info("available workspace profiles:");
await list(opts);
return;
}
const idxOf = orgWorkspaces.findIndex((x) => x.name === name);
const workspace = orgWorkspaces[idxOf];
if (!workspace.workspaceId.startsWith(WM_FORK_PREFIX)) {
if (idxOf !== -1) {
const workspace = orgWorkspaces[idxOf];
if (!workspace.workspaceId.startsWith(WM_FORK_PREFIX)) {
throw new Error(
`You can only delete forked workspaces where the workspace id starts with \`${WM_FORK_PREFIX}.\` Failed while attempting to delete \`${workspace.workspaceId}\``,
);
}
forkWorkspaceId = workspace.workspaceId;
token = workspace.token;
remote = workspace.remote;
hasLocalProfile = true;
} else {
// Fallback: resolve parent workspace from branch config and construct fork ID
const parentWorkspace = await tryResolveBranchWorkspace(opts);
if (!parentWorkspace) {
throw new Error(
"Could not resolve parent workspace. Make sure you are in a git repo with gitBranches configured in wmill.yaml, or create a local workspace profile for the fork.",
);
}
forkWorkspaceId = name.startsWith(`${WM_FORK_PREFIX}-`) ? name : `${WM_FORK_PREFIX}-${name}`;
token = parentWorkspace.token;
remote = parentWorkspace.remote;
}
if (!opts.yes) {
const { Select } = await import("@cliffy/prompt/select");
const choice = await Select.prompt({
message: `Are you sure you want to delete the forked workspace with id: \`${workspace.workspaceId}\`? This action will delete the workspace `,
options: [
{ name: "Yes", value: "confirm" },
{ name: "No", value: "cancel" },
],
});
const { Select } = await import("@cliffy/prompt/select");
const choice = await Select.prompt({
message: `Are you sure you want to delete the forked workspace \`${forkWorkspaceId}\`?`,
options: [
{ name: "Yes", value: "confirm" },
{ name: "No", value: "cancel" },
],
});
if (choice === "cancel") {
log.info("Operation cancelled");
return;
}
if (choice === "cancel") {
log.info("Operation cancelled");
return;
}
}
const remote = workspace.remote
setClient(
workspace.token,
token,
remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote
);
const result = await wmill.deleteWorkspace({
workspace: workspace.workspaceId
workspace: forkWorkspaceId
});
log.info(
colors.green(`✅ Forked workspace '${workspace.workspaceId}' deleted successfully!\n${result}`),
colors.green(`✅ Forked workspace '${forkWorkspaceId}' deleted successfully!\n${result}`),
);
await removeWorkspace(name, false, opts);
if (hasLocalProfile) {
await removeWorkspace(name, false, opts);
}
}
export { createWorkspaceFork, deleteWorkspaceFork };
+81 -11
View File
@@ -253,8 +253,12 @@ export async function add(
"On that instance and with those credentials, the workspaces that you can access are:"
);
const workspaces = await wmill.listWorkspaces();
for (const workspace of workspaces) {
log.info(`- ${workspace.id} (name: ${workspace.name})`);
if (workspaces.length === 0) {
log.info(" (none)");
} else {
for (const workspace of workspaces) {
log.info(`- ${workspace.id} (name: ${workspace.name})`);
}
}
process.exit(1);
}
@@ -411,31 +415,94 @@ async function whoami(_opts: GlobalOptions) {
const whoamiInfo = await wmill.globalWhoami();
log.info(JSON.stringify(whoamiInfo, null, 2));
const activeName = await getActiveWorkspaceName(_opts);
log.info("Active: " + colors.green.bold(activeName || "none"));
const { getCurrentGitBranch, getOriginalBranchForWorkspaceForks } = await import("../../utils/git.ts");
const branch = getCurrentGitBranch();
const originalBranch = branch ? getOriginalBranchForWorkspaceForks(branch) : null;
if (originalBranch) {
const { resolveWorkspace } = await import("../../core/context.ts");
try {
const ws = await resolveWorkspace(_opts);
log.info("Active: " + colors.green.bold(ws.workspaceId) + ` (fork of ${activeName || "unknown"})`);
} catch {
log.info("Active: " + colors.green.bold(activeName || "none") + " (fork branch)");
}
} else {
log.info("Active: " + colors.green.bold(activeName || "none"));
}
}
async function listRemote(_opts: GlobalOptions) {
const { resolveWorkspace } = await import("../../core/context.ts");
const workspace = await resolveWorkspace(_opts);
await requireLogin(_opts);
let remote: string;
if (_opts.baseUrl && _opts.token && !_opts.workspace) {
// Allow listing workspaces with just --base-url and --token (no --workspace needed)
const { setClient } = await import("../../core/client.ts");
remote = new URL(_opts.baseUrl).toString();
setClient(_opts.token, remote.replace(/\/$/, ""));
} else {
const { resolveWorkspace } = await import("../../core/context.ts");
const workspace = await resolveWorkspace(_opts);
await requireLogin(_opts);
remote = workspace.remote;
}
const userWorkspaces = await wmill.listUserWorkspaces();
const hasForks = userWorkspaces.workspaces.some((x) => x.parent_workspace_id);
const headers = hasForks
? ["id", "name", "username", "fork of", "disabled"]
: ["id", "name", "username", "disabled"];
new Table()
.header(["id", "name", "username", "disabled"])
.header(headers)
.padding(2)
.border(true)
.body(
userWorkspaces.workspaces.map((x) => [
userWorkspaces.workspaces.map((x) => {
const row = [
x.id,
x.name,
x.username,
];
if (hasForks) row.push(x.parent_workspace_id ?? "-");
row.push(x.disabled ? colors.red("true") : "false");
return row;
})
)
.render();
log.info(`Remote: ${colors.bold(remote)}`);
log.info(`Logged in as: ${colors.green.bold(userWorkspaces.email)}`);
}
async function listForks(_opts: GlobalOptions) {
const { resolveWorkspace } = await import("../../core/context.ts");
const workspace = await resolveWorkspace(_opts);
await requireLogin(_opts);
const userWorkspaces = await wmill.listUserWorkspaces();
const forks = userWorkspaces.workspaces.filter((w) => w.parent_workspace_id);
if (forks.length === 0) {
log.info("No forked workspaces found.");
return;
}
new Table()
.header(["id", "name", "fork of", "username"])
.padding(2)
.border(true)
.body(
forks.map((x) => [
x.id,
x.name,
x.parent_workspace_id ?? "",
x.username,
x.disabled ? colors.red("true") : "false",
])
)
.render();
log.info(`Remote: ${colors.bold(workspace.remote)}`);
log.info(`Logged in as: ${colors.green.bold(userWorkspaces.email)}`);
}
export async function getActiveWorkspaceOrFallback(opts: GlobalOptions) {
@@ -566,8 +633,11 @@ const command = new Command()
.command("list-remote")
.description("List workspaces on the remote server that you have access to")
.action(listRemote as any)
.command("list-forks")
.description("List forked workspaces on the remote server")
.action(listForks as any)
.command("bind")
.description("Bind the current Git branch to the active workspace")
.description("Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch.")
.option("--branch, --env <branch:string>", "Specify branch/environment (defaults to current)")
.action((opts) => bind(opts as any, true))
.command("unbind")
+11 -4
View File
@@ -57,6 +57,7 @@ export interface SyncOptions {
variables?: string[];
resources?: string[];
triggers?: string[];
schedules?: string[];
folders?: string[];
settings?: boolean;
};
@@ -70,6 +71,7 @@ export interface SyncOptions {
variables?: string[];
resources?: string[];
triggers?: string[];
schedules?: string[];
folders?: string[];
settings?: boolean;
};
@@ -83,6 +85,7 @@ export interface SyncOptions {
variables?: string[];
resources?: string[];
triggers?: string[];
schedules?: string[];
folders?: string[];
settings?: boolean;
};
@@ -96,6 +99,7 @@ export interface SyncOptions {
variables?: string[];
resources?: string[];
triggers?: string[];
schedules?: string[];
folders?: string[];
settings?: boolean;
};
@@ -191,15 +195,18 @@ export function getWmillYamlPath(): string | null {
return findWmillYaml();
}
export async function readConfigFile(): Promise<SyncOptions> {
export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promise<SyncOptions> {
const warnIfMissing = opts?.warnIfMissing ?? true;
try {
// First, try to find wmill.yaml recursively
const wmillYamlPath = findWmillYaml();
if (!wmillYamlPath) {
log.warn(
"No wmill.yaml found. Use 'wmill init' to bootstrap it."
);
if (warnIfMissing) {
log.warn(
"No wmill.yaml found. Use 'wmill init' to bootstrap it."
);
}
return {};
}
+8 -7
View File
@@ -262,8 +262,8 @@ export async function tryResolveBranchWorkspace(
}
}
// Read wmill.yaml to check for branch workspace configuration
const config = await readConfigFile();
// Read wmill.yaml to check for branch workspace configuration (silent — just probing)
const config = await readConfigFile({ warnIfMissing: false });
const branchConfig = config.gitBranches?.[currentBranch];
// Check if branch has workspace configuration
@@ -366,7 +366,7 @@ export async function tryResolveBranchWorkspace(
selectedProfile.name = `${selectedProfile.name}/${workspaceIdIfForked}`;
selectedProfile.workspaceId = workspaceIdIfForked;
log.info(
`Inferred workspace id \`${workspaceId}\` from branch name because this is a workspace fork branch (\`${rawBranch}\`). `
`Using fork workspace \`${workspaceIdIfForked}\` (parent: \`${workspaceId}\`) from branch \`${rawBranch}\``
);
}
@@ -458,15 +458,16 @@ export async function resolveWorkspace(
const branch = branchOverride ?? getCurrentGitBranch();
// Try explicit workspace flag first (should override branch-based resolution). Unless it's a
// forked workspace, that we detect through the branch name (only when not using branchOverride)
// forked workspace, that we detect through the branch name (only when not using branchOverride
// and --workspace was not explicitly provided)
const res = await tryResolveWorkspace(opts);
if (!res.isError) {
const workspace = (res as { isError: false; value: Workspace }).value;
if (branchOverride || !branch || !branch.startsWith(WM_FORK_PREFIX)) {
if (branchOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) {
return workspace;
} else {
log.info(
`Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\``
`Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`. Use --workspace to override.`
);
}
} else if (opts.workspace) {
@@ -549,7 +550,7 @@ export async function resolveWorkspace(
}
// If everything failed, show error
log.info(colors.red.bold("No workspace given and no default set."));
log.info(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one."));
return process.exit(-1);
}
+8 -1
View File
@@ -1,4 +1,5 @@
let logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR" = "INFO";
let silentMode = false;
const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
@@ -6,19 +7,25 @@ export function setup(level: "DEBUG" | "INFO" | "WARN" | "ERROR") {
logLevel = level;
}
export function setSilent(silent: boolean) {
silentMode = silent;
}
export function debug(msg: unknown) {
if (levels[logLevel] <= levels.DEBUG)
console.log(`\x1b[90m${String(msg)}\x1b[39m`);
}
export function info(msg: unknown) {
if (silentMode) return;
console.log(`\x1b[34m${String(msg)}\x1b[39m`);
}
export function warn(msg: unknown) {
if (silentMode) return;
console.log(`\x1b[33m${String(msg)}\x1b[39m`);
}
export function error(msg: unknown) {
console.log(`\x1b[31m${String(msg)}\x1b[39m`);
console.error(`\x1b[31m${String(msg)}\x1b[39m`);
}
+25 -1
View File
@@ -8,6 +8,7 @@ export interface SpecificItemsConfig {
variables?: string[];
resources?: string[];
triggers?: string[];
schedules?: string[];
folders?: string[];
settings?: boolean;
}
@@ -17,6 +18,7 @@ function getBranchSpecificTypes() {
return {
variable: '.variable.yaml',
resource: '.resource.yaml',
schedule: '.schedule.yaml',
// Generate trigger patterns from the list
...Object.fromEntries(
TRIGGER_TYPES.map(t => [`${t}_trigger`, `.${t}_trigger.yaml`])
@@ -31,6 +33,13 @@ function isTriggerFile(path: string): boolean {
return TRIGGER_TYPES.some(type => path.endsWith(`.${type}_trigger.yaml`));
}
/**
* Check if a path is a schedule file
*/
function isScheduleFile(path: string): boolean {
return path.endsWith('.schedule.yaml');
}
/**
* Extract the file type suffix from a path
*/
@@ -53,7 +62,7 @@ function getFileTypeSuffix(path: string): string | null {
* Build regex pattern for all supported yaml file types
*/
function buildYamlTypePattern(): string {
const basicTypes = ['variable', 'resource'];
const basicTypes = ['variable', 'resource', 'schedule'];
const triggerTypes = TRIGGER_TYPES.map(t => `${t}_trigger`);
return `((${basicTypes.join('|')})|(${triggerTypes.join('|')}))`;
}
@@ -100,6 +109,9 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver
if (commonItems?.triggers) {
merged.triggers = [...commonItems.triggers];
}
if (commonItems?.schedules) {
merged.schedules = [...commonItems.schedules];
}
if (commonItems?.folders) {
merged.folders = [...commonItems.folders];
}
@@ -117,6 +129,9 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver
if (branchItems?.triggers) {
merged.triggers = [...(merged.triggers || []), ...branchItems.triggers];
}
if (branchItems?.schedules) {
merged.schedules = [...(merged.schedules || []), ...branchItems.schedules];
}
if (branchItems?.folders) {
merged.folders = [...(merged.folders || []), ...branchItems.folders];
}
@@ -157,6 +172,10 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC
return specificItems.triggers !== undefined;
}
if (isScheduleFile(path)) {
return specificItems.schedules !== undefined;
}
if (path.endsWith('/folder.meta.yaml')) {
return specificItems.folders !== undefined;
}
@@ -194,6 +213,11 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig
return specificItems.triggers ? matchesPatterns(path, specificItems.triggers) : false;
}
// Check for schedule files
if (isScheduleFile(path)) {
return specificItems.schedules ? matchesPatterns(path, specificItems.schedules) : false;
}
// Check for folder meta files
if (path.endsWith('/folder.meta.yaml')) {
if (specificItems.folders) {
File diff suppressed because one or more lines are too long
+32 -4
View File
@@ -39,8 +39,13 @@ 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";
import { fetchVersion } from "./core/context.ts";
export {
@@ -62,13 +67,18 @@ export {
instance,
dev,
docs,
config,
hubPull,
pull,
push,
workspaceAdd,
job,
group,
audit,
token,
};
export const VERSION = "1.662.0";
export const VERSION = "1.670.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
@@ -130,8 +140,13 @@ 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)
.command("version --version", "Show version information")
.action(async (opts: any) => {
console.log("CLI version: " + VERSION);
@@ -215,11 +230,24 @@ async function main() {
await command.parse(args);
} catch (e) {
if (e && typeof e === "object" && "name" in e && e.name === "ApiError") {
console.log(
"Server failed. " + (e as any).statusText + ": " + (e as any).body
const body = (e as any).body;
let bodyStr = typeof body === "object" && body !== null ? JSON.stringify(body) : String(body ?? "");
// Strip backend source file references like (flows.rs:1400) or @scripts.rs:123:45
bodyStr = bodyStr.replace(/\s*[@(]\w+\.rs:\d+[:\d]*\)?/g, "");
log.error(
"Server failed. " + (e as any).statusText + ": " + bodyStr
);
} else if (e instanceof Error) {
log.error(e.message);
} else if (e !== undefined && e !== null) {
log.error(String(e));
}
throw e;
const isDebug =
process.argv.includes("--verbose") || process.argv.includes("--debug");
if (isDebug) {
throw e;
}
process.exitCode = 1;
}
}
+20 -7
View File
@@ -157,17 +157,26 @@ export async function pushObj(
const typeEnding = getTypeStrFromPath(p);
if (typeEnding === "app") {
const appName = extractResourceName(p, "app")!;
const appName = extractResourceName(p, "app");
if (!appName) {
throw new Error(`Could not extract app name from path: ${p}`);
}
await pushApp(workspace, appName, buildFolderPath(appName, "app"), message);
} else if (typeEnding === "raw_app") {
const rawAppName = extractResourceName(p, "raw_app")!;
const rawAppName = extractResourceName(p, "raw_app");
if (!rawAppName) {
throw new Error(`Could not extract raw app name from path: ${p}`);
}
await pushRawApp(workspace, rawAppName, buildFolderPath(rawAppName, "raw_app"), message);
} else if (typeEnding === "folder") {
await pushFolder(workspace, p, befObj, newObj);
} else if (typeEnding === "variable") {
await pushVariable(workspace, p, befObj, newObj, plainSecrets);
} else if (typeEnding === "flow") {
const flowName = extractResourceName(p, "flow")!;
const flowName = extractResourceName(p, "flow");
if (!flowName) {
throw new Error(`Could not extract flow name from path: ${p}`);
}
await pushFlow(workspace, flowName, buildFolderPath(flowName, "flow"), message);
} else if (typeEnding === "resource") {
if (!alreadySynced.includes(p)) {
@@ -349,12 +358,16 @@ export function removeType(str: string, type: string) {
const normalizedStr = path.normalize(str).replaceAll(SEP, "/");
if (
!normalizedStr.endsWith("." + type + ".yaml") &&
!normalizedStr.endsWith("." + type + ".json")
normalizedStr.endsWith("." + type + ".yaml") ||
normalizedStr.endsWith("." + type + ".json")
) {
throw new Error(str + " does not end with ." + type + ".(yaml|json)");
return normalizedStr.slice(0, normalizedStr.length - type.length - 6);
}
return normalizedStr.slice(0, normalizedStr.length - type.length - 6);
// Accept clean paths without the type suffix (e.g. "f/folder/name" instead of "f/folder/name.schedule.yaml")
if (normalizedStr.includes("." + type)) {
log.debug(`Path '${str}' contains '.${type}' but doesn't end with '.${type}.(yaml|json)' — treating as clean path`);
}
return normalizedStr;
}
/**
+23 -1
View File
@@ -48,7 +48,7 @@ let _nonDottedPathsLogged = false;
*/
export function setNonDottedPaths(value: boolean): void {
if (value && !_nonDottedPathsLogged) {
log.info("Using non-dotted paths (__flow, __app, __raw_app)");
log.debug("Using non-dotted paths (__flow, __app, __raw_app)");
_nonDottedPathsLogged = true;
}
_nonDottedPaths = value;
@@ -453,6 +453,28 @@ export function isRawAppFolderMetadataFile(p: string): boolean {
);
}
/**
* Check if a path ends with a specific app metadata file
* (inside the folder, e.g., ".app/app.yaml" or "__app/app.yaml")
*/
export function isAppFolderMetadataFile(p: string): boolean {
return (
p.endsWith(getMetadataPathSuffix("app", "yaml")) ||
p.endsWith(getMetadataPathSuffix("app", "json"))
);
}
/**
* Check if a path ends with a specific flow metadata file
* (inside the folder, e.g., ".flow/flow.yaml" or "__flow/flow.yaml")
*/
export function isFlowFolderMetadataFile(p: string): boolean {
return (
p.endsWith(getMetadataPathSuffix("flow", "yaml")) ||
p.endsWith(getMetadataPathSuffix("flow", "json"))
);
}
// ============================================================================
// Script Module Path Functions
// ============================================================================
+22
View File
@@ -107,6 +107,7 @@ export function getHeaders(): Record<string, string> | undefined {
export async function digestDir(path: string, conf: string) {
const hashes: string = [];
const entries = await readdir(path, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const e of entries) {
const npath = path + "/" + e.name;
if (e.isFile()) {
@@ -287,3 +288,24 @@ 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);
}
/**
* Validate that required arguments are present when no -d data was provided.
* Fetches the schema from the API and checks required fields.
* @param schema - The JSON schema object from the script/flow definition
* @throws Error if required arguments are missing
*/
export function validateRequiredArgs(
schema: Record<string, unknown> | undefined | null,
): void {
const required = (schema as { required?: string[] })?.required ?? [];
if (required.length > 0) {
throw new Error(
`Missing required arguments: ${required.join(", ")}.\nUse -d '{"${required[0]}": ...}' to provide input data.`
);
}
}
+34 -5
View File
@@ -1,9 +1,35 @@
import { parse as yamlParse, type ParseOptions } from "yaml";
import { parse as yamlParse } from "yaml";
import type { ParseOptions, DocumentOptions, SchemaOptions, ToJSOptions, ScalarTag } from "yaml";
import { readFile } from "node:fs/promises";
export async function yamlParseFile(path: string, options: ParseOptions = {}) {
// Custom YAML tags that resolve `!inline value` and `!inline_fileset value`
// back to their string-prefix form ("!inline value").
// Without these, the yaml parser strips the tag and returns just the scalar,
// breaking the string-prefix-based !inline detection used throughout the CLI.
const inlineTag: ScalarTag = {
tag: "!inline",
resolve(value: string) {
return "!inline " + value;
},
};
const inlineFilesetTag: ScalarTag = {
tag: "!inline_fileset",
resolve(value: string) {
return "!inline_fileset " + value;
},
};
const WINDMILL_CUSTOM_TAGS: ScalarTag[] = [inlineTag, inlineFilesetTag];
type YamlParseOptions = ParseOptions & DocumentOptions & SchemaOptions & ToJSOptions;
export async function yamlParseFile(path: string, options: YamlParseOptions = {}) {
try {
return yamlParse(await readFile(path, "utf-8"), options);
return yamlParse(await readFile(path, "utf-8"), {
...options,
customTags: [...WINDMILL_CUSTOM_TAGS, ...((options.customTags as ScalarTag[] | undefined) ?? [])],
});
} catch (e) {
throw new Error(`Error parsing yaml ${path}`, { cause: e });
}
@@ -12,10 +38,13 @@ export async function yamlParseFile(path: string, options: ParseOptions = {}) {
export function yamlParseContent(
path: string,
content: string,
options: ParseOptions = {},
options: YamlParseOptions = {},
) {
try {
return yamlParse(content, options);
return yamlParse(content, {
...options,
customTags: [...WINDMILL_CUSTOM_TAGS, ...((options.customTags as ScalarTag[] | undefined) ?? [])],
});
} catch (e) {
throw new Error(`Error parsing yaml ${path}`, { cause: e });
}