mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
Merge remote-tracking branch 'origin/main' into fork-datatable-schema-export
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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"])
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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`));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+121
-27
File diff suppressed because one or more lines are too long
+32
-4
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user