Compare commits

...

11 Commits

Author SHA1 Message Date
Guilhem Lemouel
0ac96b96e6 fix(cli): handle mixed flow suffixes in dev file watcher
The ignore() function uses isFlowPath() which only checks the configured
suffix (__flow or .flow), causing files in the other variant to be silently
ignored. Bypass the ignore check for any file inside a flow folder and
force flow type detection regardless of suffix configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:54:36 +01:00
Guilhem Lemouel
0c1d71070a chore: regenerate auto-generated CLI skills for new dev flags
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 19:29:25 +01:00
Guilhem Lemouel
371eb430ac merge: sync with main
Resolved conflict in FlowModuleSchemaMap.svelte, taking main's
refactored move logic with affectedGroups/commit pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:16:50 +01:00
Guilhem Lemouel
8009960ab1 feat(cli): add Claude CLI/Desktop detection hints in wmill flow new
Show contextual instructions for previewing flows based on available tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:12:57 +01:00
Guilhem Lemouel
3272c29c2e fix(cli): include path in dev URL and use open.default for browser
- Append &path= to the printed/opened URL when --path is specified
- Use open.default(url) instead of open.openApp for more reliable browser opening

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:51:43 +01:00
Guilhem Lemouel
58fbfc181c docs(cli): update generated skills with dev workflow and preview commands
Update cli-commands, write-flow, and raw-app skills to document the new
local dev workflow (wmill dev --path, --proxy-port, .claude/launch.json).
Add wmill script preview and wmill flow preview to all script/flow skills
so agents know how to test without deploying.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:32:23 +01:00
Guilhem Lemouel
a4ef96f056 fix(cli): handle both .flow and __flow suffixes in wmill dev
The flow detection in loadPaths only checked the configured suffix
(dotted or non-dotted), so users with nonDottedPaths=true who had
.flow folders (or vice versa) would see inline script edits treated
as standalone script changes instead of flow changes.

Now checks both suffix forms everywhere: type classification,
folder path extraction, path stripping, and loadWmPath lookup.
Also adds raw_app launch.json generation to init and sync pull.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:08:53 +01:00
Guilhem Lemouel
d1ee68c376 feat(cli): enhance app new with Claude Desktop integration and better defaults
- Add .claude/launch.json to generated app scaffold for Claude Code preview support
- Add "Open in Claude Desktop?" prompt that creates a CLI session and opens it
  in Claude Desktop Code mode via the claude://resume deep link
- Improve default CSS template with body background, system fonts, and padding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:13:10 +01:00
Guilhem Lemouel
154ffe59bb fix: clamp flow graph height between minHeight and maxHeight
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 17:09:04 +01:00
Guilhem Lemouel
62b57ae4a2 feat: responsive dev layout and hide splitter for single-pane views
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 16:46:49 +01:00
Guilhem Lemouel
f91f56317b feat(cli): add wmill flow dev subcommand with per-flow reverse proxy and launch.json
Also generates .claude/launch.json for existing flow folders during `wmill init`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:10:35 +01:00
13 changed files with 920 additions and 85 deletions

View File

@@ -12,6 +12,7 @@ import { resolveWorkspace } from "../../core/context.ts";
import { requireLogin } from "../../core/auth.ts";
import * as wmill from "../../../gen/services.gen.ts";
import path from "node:path";
import { execSync, exec } from "node:child_process";
import {
buildFolderPath,
loadNonDottedPathsSetting,
@@ -99,7 +100,18 @@ import "./index.css";
createApp(App).mount('#root')`;
const indexCss = `.myclass {
const indexCss = `body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #1a1a1a;
}
#root {
padding: 24px;
}
.myclass {
border: 1px solid gray;
padding: 2px;
}`;
@@ -498,6 +510,26 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName};
await mkdir(appDir, { recursive: true });
await mkdir(path.join(appDir, "backend"), { recursive: true });
await mkdir(path.join(appDir, "sql_to_apply"), { recursive: true });
await mkdir(path.join(appDir, ".claude"), { recursive: true });
// Create .claude/launch.json for Claude Code preview
const launchJson = {
version: "0.0.1",
configurations: [
{
name: "windmill",
runtimeExecutable: "bash",
runtimeArgs: ["-c", "wmill app dev --no-open --port ${PORT:-4000}"],
port: 4000,
autoPort: true,
},
],
};
await writeFile(
path.join(appDir, ".claude", "launch.json"),
JSON.stringify(launchJson, null, 2) + "\n",
"utf-8"
);
// Create raw_app.yaml with data configuration
const rawAppConfig: Record<string, unknown> = {
@@ -610,6 +642,8 @@ This folder is for SQL migration files that will be applied to datatables during
log.info("");
log.info(colors.gray("Directory structure:"));
log.info(colors.gray(` ${folderName}/`));
log.info(colors.gray(" ├── .claude/"));
log.info(colors.gray(" │ └── launch.json"));
log.info(colors.gray(" ├── AGENTS.md ← Read this first!"));
log.info(colors.gray(" ├── raw_app.yaml"));
log.info(colors.gray(" ├── DATATABLES.md"));
@@ -662,6 +696,67 @@ This folder is for SQL migration files that will be applied to datatables during
}
log.info("");
log.info(colors.gray(" 4. wmill sync push (to deploy when ready)"));
// Offer to open in Claude Desktop
let hasClaudeDesktop = false;
try {
execSync("ls /Applications/Claude.app", { stdio: "ignore" });
hasClaudeDesktop = true;
} catch {
// Claude Desktop not installed
}
if (hasClaudeDesktop) {
log.info("");
const openInDesktop = await Confirm.prompt({
message: "Open in Claude Desktop?",
default: true,
});
if (openInDesktop) {
try {
const absAppDir = path.resolve(appDir);
const sessionId = crypto.randomUUID();
// Create a persisted CLI session with welcome message (async to allow spinner)
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
const spinner = setInterval(() => {
process.stdout.write(`\r${colors.gray(`${frames[i++ % frames.length]} Creating Claude session...`)}`);
}, 80);
await new Promise<void>((resolve, reject) => {
exec(
`claude --session-id "${sessionId}" -p "Say: Your app is ready, click on preview to test it!"`,
{ cwd: absAppDir },
(error) => (error ? reject(error) : resolve())
);
});
clearInterval(spinner);
process.stdout.write("\r" + " ".repeat(40) + "\r");
// Import the session into Claude Desktop Code mode
const deepLink = `claude://resume?session=${sessionId}&cwd=${encodeURIComponent(absAppDir)}`;
exec(`open ${JSON.stringify(deepLink)}`);
log.info(colors.bold.green("Opened in Claude Desktop!"));
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
log.warn(
colors.yellow(
`Could not open in Claude Desktop: ${errorMessage}`
)
);
log.info(
colors.gray(
"You can manually run: cd " + folderName + " && claude"
)
);
}
}
}
}
const command = new Command()

View File

@@ -2,18 +2,21 @@ import { Command } from "@cliffy/command";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import { yamlParseFile } from "../../utils/yaml.ts";
import { stringify as yamlStringify } from "yaml";
import { WebSocket, WebSocketServer } from "ws";
import * as getPort from "get-port";
import * as http from "node:http";
import * as https from "node:https";
import * as open from "open";
import { readFile, realpath } from "node:fs/promises";
import { access, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
import { watch } from "node:fs";
import { getTypeStrFromPath, GlobalOptions } from "../../types.ts";
import { ignoreF } from "../sync/sync.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace } from "../../core/context.ts";
import {
GLOBAL_CONFIG_OPT,
SyncOptions,
mergeConfigWithConfigFile,
} from "../../core/conf.ts";
@@ -22,17 +25,134 @@ import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
import { OpenFlow } from "../../../gen/types.gen.ts";
import { FlowFile } from "../flow/flow.ts";
import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
import { extractInlineScripts, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { parseMetadataFile } from "../../utils/metadata.ts";
import {
getFolderSuffixWithSep,
getMetadataFileName,
extractFolderPath,
getNonDottedPaths,
loadNonDottedPathsSetting,
} from "../../utils/resource_folders.ts";
import * as path from "node:path";
import * as fs from "node:fs";
import { listSyncCodebases } from "../../utils/codebase.ts";
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
const PORT = 3001;
async function dev(opts: GlobalOptions & SyncOptions) {
// PathScript snapshot/restore for flow round-trip
const TAG_KEY = "_originalPathScript" as const;
function walkFlowModules(modules: any[], visitor: { onModule: (m: any) => void }) {
for (const module of modules) {
if (!module.value) continue;
const val = module.value;
if (val.type === "forloopflow" || val.type === "whileloopflow") {
walkFlowModules(val.modules, visitor);
} else if (val.type === "branchall") {
for (const branch of val.branches ?? []) {
walkFlowModules(branch.modules, visitor);
}
} else if (val.type === "branchone") {
for (const branch of val.branches ?? []) {
walkFlowModules(branch.modules, visitor);
}
if (val.default) {
walkFlowModules(val.default, visitor);
}
} else {
visitor.onModule(module);
}
}
}
function walkFlow(flowValue: any, visitor: { onModule: (m: any) => void }) {
if (flowValue?.modules) walkFlowModules(flowValue.modules, visitor);
if (flowValue?.failure_module) walkFlowModules([flowValue.failure_module], visitor);
if (flowValue?.preprocessor_module) walkFlowModules([flowValue.preprocessor_module], visitor);
}
function snapshotPathScripts(flowValue: any) {
walkFlow(flowValue, {
onModule(module) {
if (module.value.type === "script") {
module[TAG_KEY] = JSON.parse(JSON.stringify(module.value));
}
},
});
}
function tagReplacedPathScripts(flowValue: any) {
walkFlow(flowValue, {
onModule(module) {
if (module[TAG_KEY] && module.value.type === "rawscript") {
module.value[TAG_KEY] = module[TAG_KEY];
delete module[TAG_KEY];
} else if (module[TAG_KEY]) {
delete module[TAG_KEY];
}
},
});
}
function restorePathScripts(flowValue: any) {
walkFlow(flowValue, {
onModule(module) {
if (module.value[TAG_KEY]) {
module.value = module.value[TAG_KEY];
}
},
});
}
export interface DevOpts {
proxyPort?: number;
path?: string;
}
export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
// Auto-detect flow folder: if no --path and cwd is a flow folder, resolve path and chdir to workspace root
if (!opts.path) {
const cwd = process.cwd();
const cwdBasename = path.basename(cwd);
// Need to init nonDottedPaths before checking suffix
await loadNonDottedPathsSetting();
if (cwdBasename.endsWith(".flow") || cwdBasename.endsWith("__flow")) {
GLOBAL_CONFIG_OPT.noCdToRoot = true;
// Find workspace root
let searchDir = cwd;
let workspaceRoot: string | undefined;
while (true) {
const wmillYaml = path.join(searchDir, "wmill.yaml");
if (fs.existsSync(wmillYaml)) {
workspaceRoot = searchDir;
break;
}
const parentDir = path.dirname(searchDir);
if (parentDir === searchDir) break;
searchDir = parentDir;
}
if (workspaceRoot) {
const relPath = path.relative(workspaceRoot, cwd).replaceAll("\\", "/");
// Strip whichever flow suffix is actually present (dotted or non-dotted)
if (relPath.endsWith(".flow")) {
opts.path = relPath.slice(0, -".flow".length);
} else if (relPath.endsWith("__flow")) {
opts.path = relPath.slice(0, -"__flow".length);
} else {
opts.path = relPath;
}
opts.proxyPort = opts.proxyPort ?? 3100;
log.info(`Detected flow folder, path: ${opts.path}`);
process.chdir(workspaceRoot);
}
}
}
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
@@ -70,12 +190,13 @@ async function dev(opts: GlobalOptions & SyncOptions) {
});
}
const flowFolderSuffix = getFolderSuffixWithSep("flow");
const flowMetadataFile = getMetadataFileName("flow", "yaml");
async function loadPaths(pathsToLoad: string[]) {
const paths = pathsToLoad.filter((path) =>
const paths = pathsToLoad.filter((p) =>
exts.some(
(ext) => path.endsWith(ext) || path.endsWith(flowFolderSuffix + flowMetadataFile)
(ext) => p.endsWith(ext)
|| p.endsWith(".flow/" + flowMetadataFile)
|| p.endsWith("__flow/" + flowMetadataFile)
)
);
if (paths.length == 0) {
@@ -83,11 +204,39 @@ async function dev(opts: GlobalOptions & SyncOptions) {
}
const nativePath = (await realpath(paths[0])).replace(base + SEP, "");
const cpath = nativePath.replaceAll("\\", "/");
if (!ignore(nativePath, false)) {
const typ = getTypeStrFromPath(cpath);
// Bypass ignore for paths inside flow folders — ignore() only checks the configured
// suffix (dotted or non-dotted), but the workspace may contain both kinds
const isInFlowFolder = cpath.includes(".flow/") || cpath.includes("__flow/");
if (isInFlowFolder || !ignore(nativePath, false)) {
let typ: string;
if (isInFlowFolder) {
// Force flow type for any file inside a flow folder — getTypeStrFromPath
// only recognises the configured suffix (dotted or non-dotted) and would
// mis-classify or throw for the other variant
typ = "flow";
} else {
typ = getTypeStrFromPath(cpath);
// If a script file is inside a flow folder, treat it as a flow change
// (handles both .flow/ and __flow/ regardless of nonDottedPaths setting)
if (typ === "script" && (cpath.includes(".flow/") || cpath.includes("__flow/"))) {
typ = "flow";
}
}
log.info("Detected change in " + cpath + " (" + typ + ")");
if (typ == "flow") {
const localPath = extractFolderPath(cpath, "flow")!;
// Try extractFolderPath, fallback to manual extraction for mixed suffix cases
let localPath = extractFolderPath(cpath, "flow");
if (!localPath) {
// extractFolderPath only checks the configured suffix; try both manually
for (const suffix of [".flow/", "__flow/"]) {
const idx = cpath.indexOf(suffix);
if (idx !== -1) {
localPath = cpath.substring(0, idx) + suffix;
break;
}
}
}
if (!localPath) return;
const localFlow = (await yamlParseFile(
localPath + "flow.yaml"
)) as FlowFile;
@@ -99,19 +248,29 @@ async function dev(opts: GlobalOptions & SyncOptions) {
SEP,
undefined,
);
// Replace PathScript modules with local file content so dev mode uses local versions
// Snapshot PathScript modules before replacement, then tag after
snapshotPathScripts(localFlow);
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
tagReplacedPathScripts(localFlow);
// Strip whichever flow suffix is present (dotted or non-dotted)
let wmFlowPath = localPath.replace(/\/$/, "");
if (wmFlowPath.endsWith(".flow")) {
wmFlowPath = wmFlowPath.slice(0, -".flow".length);
} else if (wmFlowPath.endsWith("__flow")) {
wmFlowPath = wmFlowPath.slice(0, -"__flow".length);
}
currentLastEdit = {
type: "flow",
flow: localFlow,
uriPath: localPath,
path: wmFlowPath,
};
log.info("Updated " + localPath);
log.info("Updated " + wmFlowPath);
broadcastChanges(currentLastEdit);
} else if (typ == "script") {
const content = await readFile(cpath, "utf-8");
@@ -153,8 +312,162 @@ async function dev(opts: GlobalOptions & SyncOptions) {
type: "flow";
flow: OpenFlow;
uriPath: string;
path: string;
};
// Normalize a windmill path by stripping any trailing flow/app suffix
function normalizeWmPath(p: string): string {
let result = p.replace(/\/$/, "");
// Strip whichever flow suffix is present (dotted or non-dotted)
if (result.endsWith(".flow")) {
result = result.slice(0, -".flow".length);
} else if (result.endsWith("__flow")) {
result = result.slice(0, -"__flow".length);
}
return result;
}
// Load a resource by its windmill path (e.g., "u/admin/my_script" or "f/my_flow")
async function loadWmPath(wmPath: string): Promise<LastEditScript | LastEditFlow | undefined> {
wmPath = normalizeWmPath(wmPath);
// Try as flow — check both dotted and non-dotted suffixes
let flowDir: string | undefined;
let flowYaml: string | undefined;
for (const suffix of [".flow", "__flow"]) {
const candidate = wmPath + suffix + "/";
try {
await access(candidate + "flow.yaml");
flowDir = candidate;
flowYaml = candidate + "flow.yaml";
break;
} catch {}
}
try {
if (!flowDir || !flowYaml) throw new Error("not a flow");
const localFlow = (await yamlParseFile(flowYaml)) as FlowFile;
await replaceInlineScripts(
localFlow.value.modules,
async (p: string) => await readFile(flowDir + p, "utf-8"),
log,
flowDir,
SEP,
undefined,
);
snapshotPathScripts(localFlow);
const localScriptReader = createPreviewLocalScriptReader({
exts,
defaultTs: opts.defaultTs,
codebases,
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
tagReplacedPathScripts(localFlow);
const edit: LastEditFlow = {
type: "flow",
flow: localFlow,
uriPath: flowDir,
path: wmPath,
};
currentLastEdit = edit;
return edit;
} catch {
// Not a flow, try as script
}
// Try as script
for (const ext of exts) {
const filePath = wmPath + ext;
try {
await access(filePath);
const content = await readFile(filePath, "utf-8");
const lang = inferContentTypeFromFilePath(filePath, opts.defaultTs);
const typed = (await parseMetadataFile(removeExtensionToPath(filePath), undefined))?.payload;
const edit: LastEditScript = {
type: "script",
content,
path: wmPath,
language: lang,
tag: typed?.tag,
lock: typed?.lock,
};
currentLastEdit = edit;
return edit;
} catch {
continue;
}
}
log.error(`Could not find file for path: ${wmPath}`);
return undefined;
}
// Handle flow edits from the dev UI — write changes back to disk
async function handleFlowRoundTrip(data: { flow: any; uriPath: string }) {
if (!data.uriPath || !data.flow?.value) return;
let flowDir = data.uriPath;
if (!flowDir.endsWith("/")) flowDir += "/";
if (flowDir.includes("://")) {
flowDir = new URL(flowDir).pathname;
}
restorePathScripts(data.flow.value);
const flowYamlPath = flowDir + "flow.yaml";
let currentModules: any[] | undefined;
try {
const currentFlow = (await yamlParseFile(flowYamlPath)) as FlowFile;
currentModules = currentFlow.value?.modules;
} catch {
// flow.yaml doesn't exist yet or is invalid
}
const inlineScriptMapping: Record<string, string> = {};
extractCurrentMapping(currentModules, inlineScriptMapping);
const allExtracted = extractInlineScripts(
data.flow.value.modules ?? [],
inlineScriptMapping,
"/",
opts.defaultTs ?? "bun",
undefined,
{ skipInlineScriptSuffix: getNonDottedPaths() },
);
for (const s of allExtracted) {
const filePath = flowDir + s.path;
let needsWrite = true;
try {
const existing = await readFile(filePath, "utf-8");
if (existing === s.content) needsWrite = false;
} catch {
// File doesn't exist
}
if (needsWrite) {
await writeFile(filePath, s.content, "utf-8");
log.info(`Wrote inline script: ${filePath}`);
}
}
const flowYaml = yamlStringify(data.flow);
await writeFile(flowYamlPath, flowYaml, "utf-8");
log.info(`Wrote flow: ${flowYamlPath}`);
// Clean up orphaned inline script files
const extractedPaths = new Set(allExtracted.map((s) => s.path));
try {
const dirFiles = await readdir(flowDir);
for (const file of dirFiles) {
if (file === "flow.yaml" || file === "flow.json" || file.startsWith(".")) continue;
if (!extractedPaths.has(file)) {
await unlink(flowDir + file);
log.info(`Removed orphaned file: ${flowDir + file}`);
}
}
} catch {
// Directory read failed
}
}
const connectedClients: Set<WebSocket> = new Set();
// Function to send a message to all connected clients
@@ -164,27 +477,14 @@ async function dev(opts: GlobalOptions & SyncOptions) {
}
}
async function startApp() {
const server = http.createServer((_req, res) => {
res.writeHead(200);
res.end();
});
const wss = new WebSocketServer({ server });
// WebSocket server event listeners
function setupDevWs(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => {
connectedClients.add(ws);
console.log("New client connected");
ws.on("open", () => {
if (currentLastEdit) {
broadcastChanges(currentLastEdit);
}
});
console.log("New dev client connected");
ws.on("close", () => {
connectedClients.delete(ws);
console.log("Client disconnected");
console.log("Dev client disconnected");
});
ws.on("message", (message: WebSocket.RawData) => {
@@ -198,19 +498,161 @@ async function dev(opts: GlobalOptions & SyncOptions) {
if (data.type === "load") {
loadPaths([data.path]);
} else if (data.type === "flow") {
handleFlowRoundTrip(data).catch((err) => {
log.error(`Failed to write flow changes: ${err}`);
});
} else if (data.type === "loadWmPath") {
loadWmPath(data.path).then((edit) => {
if (edit && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(edit));
}
}).catch((err) => {
log.error(`Failed to load path ${data.path}: ${err}`);
});
}
});
});
}
// Start the server
const port = await getPort.default({ port: 3001 });
// --- Reverse proxy (when --proxy-port is set) ---
async function startProxyServer(proxyPort: number) {
const remote = new URL(workspace.remote);
const isHttps = remote.protocol === "https:";
const remoteHost = remote.hostname;
const remotePort = remote.port ? parseInt(remote.port) : (isHttps ? 443 : 80);
const httpModule = isHttps ? https : http;
const devWss = new WebSocketServer({ noServer: true });
setupDevWs(devWss);
const proxyWss = new WebSocketServer({ noServer: true });
const proxyServer = http.createServer((clientReq, clientRes) => {
const parsedUrl = new URL(clientReq.url ?? "/", `http://localhost`);
if (parsedUrl.pathname === "/" || parsedUrl.pathname === "") {
let devUrl = `/dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}&port=${proxyPort}`;
if (opts.path) {
devUrl += `&path=${opts.path}`;
}
clientRes.writeHead(302, { Location: devUrl });
clientRes.end();
return;
}
const fwdHeaders: Record<string, string | string[] | undefined> = {
...clientReq.headers,
host: remote.host,
};
delete fwdHeaders["connection"];
delete fwdHeaders["keep-alive"];
delete fwdHeaders["transfer-encoding"];
delete fwdHeaders["accept-encoding"];
const proxyOpts: http.RequestOptions = {
hostname: remoteHost,
port: remotePort,
path: clientReq.url,
method: clientReq.method,
headers: fwdHeaders,
};
const proxyReq = httpModule.request(proxyOpts, (proxyRes) => {
const setCookie = proxyRes.headers["set-cookie"];
if (setCookie) {
proxyRes.headers["set-cookie"] = setCookie.map((cookie) =>
cookie.replace(/domain=[^;]+/gi, "domain=localhost")
);
}
clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
proxyRes.pipe(clientRes, { end: true });
});
proxyReq.on("error", (err) => {
console.error("Proxy error:", err.message);
clientRes.writeHead(502);
clientRes.end("Bad Gateway");
});
clientReq.pipe(proxyReq, { end: true });
});
// WebSocket upgrades
proxyServer.on("upgrade", (req, socket, head) => {
const pathname = req.url?.split("?")[0] ?? "";
if (pathname === "/ws_dev" || pathname === "/ws") {
devWss.handleUpgrade(req, socket, head, (ws) => {
devWss.emit("connection", ws, req);
});
return;
}
if (pathname.startsWith("/ws/") || pathname.startsWith("/ws_mp/") || pathname.startsWith("/ws_debug/")) {
const wsProtocol = isHttps ? "wss" : "ws";
const remoteWsUrl = `${wsProtocol}://${remote.host}${req.url}`;
const remoteWs = new WebSocket(remoteWsUrl, {
headers: {
...req.headers,
host: remote.host,
},
});
remoteWs.on("open", () => {
proxyWss.handleUpgrade(req, socket, head, (clientWs) => {
clientWs.on("message", (data) => {
if (remoteWs.readyState === WebSocket.OPEN) {
remoteWs.send(data);
}
});
remoteWs.on("message", (data) => {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data);
}
});
clientWs.on("close", () => remoteWs.close());
remoteWs.on("close", () => clientWs.close());
});
});
remoteWs.on("error", (err) => {
console.error("WebSocket proxy error:", err.message);
socket.destroy();
});
return;
}
socket.destroy();
});
return new Promise<void>((resolve) => {
proxyServer.listen(proxyPort, () => {
console.log(`Dev proxy listening on http://localhost:${proxyPort}`);
resolve();
});
});
}
// --- Legacy server (direct WebSocket, no proxy) ---
async function startLegacyServer() {
const server = http.createServer((_req, res) => {
res.writeHead(200);
res.end();
});
const wss = new WebSocketServer({ server });
setupDevWs(wss);
const port = await getPort.default({ port: PORT });
const url =
`${workspace.remote}dev?workspace=${workspace.workspaceId}&local=true&wm_token=${workspace.token}` +
(port === PORT ? "" : `&port=${port}`);
(port === PORT ? "" : `&port=${port}`) +
(opts.path ? `&path=${opts.path}` : "");
console.log(`Go to ${url}`);
try {
open.openApp(open.apps.browser, { arguments: [url] }).catch((error) => {
open.default(url).catch((error) => {
console.error(
`Failed to open browser, please navigate to ${url}, error: ${error}`
);
@@ -231,7 +673,18 @@ async function dev(opts: GlobalOptions & SyncOptions) {
});
}
await Promise.all([startApp(), watchChanges()]);
// --- Start ---
// If --path is set, load it immediately
if (opts.path) {
await loadWmPath(opts.path);
}
const startServer = opts.proxyPort
? () => startProxyServer(opts.proxyPort!)
: () => startLegacyServer();
await Promise.all([startServer(), watchChanges()]);
console.log("Stopped dev mode");
}
@@ -241,6 +694,14 @@ const command = new Command()
"--includes <pattern...:string>",
"Filter paths givena glob pattern or path"
)
.option(
"--proxy-port <port:number>",
"Port for a localhost reverse proxy to the remote Windmill server"
)
.option(
"--path <path:string>",
"Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)"
)
.action(dev as any);
export default command;

View File

@@ -4,7 +4,8 @@ import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
import { Table } from "@cliffy/table";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import { sep as SEP, join as pathJoin, resolve as pathResolve } from "node:path";
import { execSync } from "node:child_process";
import { stringify as yamlStringify } from "yaml";
import { yamlParseFile } from "../../utils/yaml.ts";
import * as wmill from "../../../gen/services.gen.ts";
@@ -541,6 +542,55 @@ export function bootstrap(
const flowYamlPath = `${flowDirFullPath}/flow.yaml`;
writeFileSync(flowYamlPath, newFlowDefinitionYaml, { flag: "wx", encoding: "utf-8" });
// Generate .claude/launch.json for Claude Code preview
const claudeDir = pathJoin(flowDirFullPath, ".claude");
mkdirSync(claudeDir, { recursive: true });
const launchJson = {
version: "0.0.1",
configurations: [{
name: "windmill",
runtimeExecutable: "bash",
runtimeArgs: ["-c", "wmill dev --proxy-port ${PORT:-4000}"],
port: 4000,
autoPort: true,
}],
};
writeFileSync(
pathJoin(claudeDir, "launch.json"),
JSON.stringify(launchJson, null, 2) + "\n",
"utf-8",
);
log.info(colors.green(`Created flow at ${flowDirFullPath}`));
// Detect Claude CLI and Claude Desktop
let hasClaudeCli = false;
let hasClaudeDesktop = false;
try {
execSync("which claude", { stdio: "ignore" });
hasClaudeCli = true;
} catch {}
try {
execSync("ls /Applications/Claude.app", { stdio: "ignore" });
hasClaudeDesktop = true;
} catch {}
if (hasClaudeCli && hasClaudeDesktop) {
log.info("");
log.info(colors.bold("To develop this flow with Claude Desktop:"));
log.info(colors.gray(` 1. cd ${flowDirFullPath} && claude`));
log.info(colors.gray(` 2. Type /desktop to open the session in Claude Desktop`));
} else if (hasClaudeCli) {
log.info("");
log.info(colors.bold("To preview this flow:"));
log.info(colors.gray(` wmill dev --path ${flowPath}`));
} else if (hasClaudeDesktop) {
const absFlowDir = pathResolve(flowDirFullPath);
log.info("");
log.info(colors.bold("To develop this flow with Claude Desktop:"));
log.info(colors.gray(` Open Claude Desktop with "${absFlowDir}" as root folder`));
}
}
const command = new Command()

View File

@@ -1,4 +1,6 @@
import { stat, writeFile, rm, mkdir } from "node:fs/promises";
import { stat, writeFile, rm, mkdir, readdir } from "node:fs/promises";
import { join } from "node:path";
import { writeFileSync, mkdirSync } from "node:fs";
import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
@@ -354,6 +356,92 @@ async function initAction(opts: InitOptions) {
}
}
// Generate .claude/launch.json for each flow folder
try {
const flowSuffix = nonDottedPaths ? "__flow" : ".flow";
const flowLaunchJson = JSON.stringify({
version: "0.0.1",
configurations: [{
name: "windmill",
runtimeExecutable: "bash",
runtimeArgs: ["-c", "wmill dev --proxy-port ${PORT:-4000}"],
port: 4000,
autoPort: true,
}],
}, null, 2) + "\n";
let flowCount = 0;
async function scanForFlows(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = join(dir, entry.name);
if (entry.name.endsWith(flowSuffix)) {
const claudeDir = join(fullPath, ".claude");
const launchPath = join(claudeDir, "launch.json");
mkdirSync(claudeDir, { recursive: true });
writeFileSync(launchPath, flowLaunchJson, "utf-8");
flowCount++;
} else {
await scanForFlows(fullPath);
}
}
}
await scanForFlows(".");
if (flowCount > 0) {
log.info(colors.green(`Created .claude/launch.json for ${flowCount} flow folder(s)`));
}
} catch (error) {
log.warn(
`Could not scan for flow folders: ${error instanceof Error ? error.message : error}`
);
}
// Generate .claude/launch.json for each raw_app folder
try {
const rawAppSuffix = nonDottedPaths ? "__raw_app" : ".raw_app";
const appLaunchJson = JSON.stringify({
version: "0.0.1",
configurations: [{
name: "windmill",
runtimeExecutable: "bash",
runtimeArgs: ["-c", "wmill app dev --no-open --port ${PORT:-4000}"],
port: 4000,
autoPort: true,
}],
}, null, 2) + "\n";
let appCount = 0;
async function scanForApps(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = join(dir, entry.name);
if (entry.name.endsWith(rawAppSuffix)) {
const claudeDir = join(fullPath, ".claude");
const launchPath = join(claudeDir, "launch.json");
mkdirSync(claudeDir, { recursive: true });
writeFileSync(launchPath, appLaunchJson, "utf-8");
appCount++;
} else {
await scanForApps(fullPath);
}
}
}
await scanForApps(".");
if (appCount > 0) {
log.info(colors.green(`Created .claude/launch.json for ${appCount} raw app folder(s)`));
}
} catch (error) {
log.warn(
`Could not scan for raw app folders: ${error instanceof Error ? error.message : error}`
);
}
// Generate resource type namespace
try {
await generateRTNamespace(opts as GlobalOptions);

View File

@@ -1,6 +1,7 @@
import { requireLogin } from "../../core/auth.ts";
import { fetchVersion, resolveWorkspace } from "../../core/context.ts";
import { readFile, writeFile, readdir, stat, rm, copyFile, mkdir } from "node:fs/promises";
import { mkdirSync, writeFileSync } from "node:fs";
import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
@@ -2305,6 +2306,86 @@ export async function pull(
false,
);
}
// Generate .claude/launch.json for all flow folders
try {
const flowSuffix = getFolderSuffix("flow");
const flowLaunchJson = JSON.stringify({
version: "0.0.1",
configurations: [{
name: "windmill",
runtimeExecutable: "bash",
runtimeArgs: ["-c", "wmill dev --proxy-port ${PORT:-4000}"],
port: 4000,
autoPort: true,
}],
}, null, 2) + "\n";
let flowLaunchCount = 0;
async function scanForFlows(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = path.join(dir, entry.name);
if (entry.name.endsWith(flowSuffix)) {
const claudeDir = path.join(fullPath, ".claude");
mkdirSync(claudeDir, { recursive: true });
writeFileSync(path.join(claudeDir, "launch.json"), flowLaunchJson, "utf-8");
flowLaunchCount++;
} else {
await scanForFlows(fullPath);
}
}
}
await scanForFlows(".");
if (flowLaunchCount > 0) {
log.info(colors.green(`Created .claude/launch.json for ${flowLaunchCount} flow folder(s)`));
}
} catch (error) {
log.warn(`Could not scan for flow folders: ${error instanceof Error ? error.message : error}`);
}
// Generate .claude/launch.json for all raw_app folders
try {
const rawAppSuffix = getFolderSuffix("raw_app");
const appLaunchJson = JSON.stringify({
version: "0.0.1",
configurations: [{
name: "windmill",
runtimeExecutable: "bash",
runtimeArgs: ["-c", "wmill app dev --no-open --port ${PORT:-4000}"],
port: 4000,
autoPort: true,
}],
}, null, 2) + "\n";
let appLaunchCount = 0;
async function scanForApps(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = path.join(dir, entry.name);
if (entry.name.endsWith(rawAppSuffix)) {
const claudeDir = path.join(fullPath, ".claude");
mkdirSync(claudeDir, { recursive: true });
writeFileSync(path.join(claudeDir, "launch.json"), appLaunchJson, "utf-8");
appLaunchCount++;
} else {
await scanForApps(fullPath);
}
}
}
await scanForApps(".");
if (appLaunchCount > 0) {
log.info(colors.green(`Created .claude/launch.json for ${appLaunchCount} raw app folder(s)`));
}
} catch (error) {
log.warn(`Could not scan for raw app folders: ${error instanceof Error ? error.message : error}`);
}
if (tracker.apps.length > 0) {
log.info(
colors.gray(

View File

@@ -5015,6 +5015,8 @@ Launch a dev server that will spawn a webserver with HMR
**Options:**
- \`--includes <pattern...:string>\` - Filter paths givena glob pattern or path
- \`--proxy-port <port:number>\` - Port for a localhost reverse proxy to the remote Windmill server
- \`--path <path:string>\` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)
### docs

View File

@@ -111,6 +111,9 @@
let darkModeToggle: DarkModeToggle | undefined = $state()
let darkMode: boolean = $state(document.documentElement.classList.contains('dark'))
let flowContainerWidth = $state(0)
let flowContainerHeight = $state(0)
let flowHorizontalSplit = $derived(flowContainerWidth < flowContainerHeight)
let modeInitialized = $state(false)
function initializeMode() {
modeInitialized = true
@@ -344,6 +347,14 @@
try {
socket = new WebSocket(`ws://localhost:${port}/ws`)
// On connect, request a specific path if one is specified
socket.addEventListener('open', () => {
const watchPath = searchParams?.get('path')
if (watchPath && socket) {
socket.send(JSON.stringify({ type: 'loadWmPath', path: watchPath }))
}
})
// Listen for messages
socket.addEventListener('message', (event) => {
replaceData(event.data)
@@ -357,6 +368,15 @@
console.log('Received invalid JSON: ' + msg)
return
}
// Client-side filtering: only accept messages matching the watched path
// Normalize by stripping common folder suffixes so "f/foo__flow" matches "f/foo"
const watchPath = searchParams
?.get('path')
?.replace(/(\.(flow|app|raw_app)|__(flow|app|raw_app))\/?$/, '')
const dataPath = data.path?.replace(/(\.(flow|app|raw_app)|__(flow|app|raw_app))\/?$/, '')
if (watchPath && dataPath && dataPath !== watchPath) {
return
}
if (data.type == 'script') {
replaceScript(data)
} else if (data.type == 'flow') {
@@ -568,13 +588,20 @@
setGroupEditorContext(groupEditor, canCreateGroup)
let lastSent: OpenFlow | undefined = undefined
const isInIframe = window.parent !== window
function updateFlow(flow: OpenFlow) {
if (lockChanges) {
return
}
if (!deepEqual(flow, lastSent)) {
lastSent = $state.snapshot(flow)
window?.parent.postMessage({ type: 'flow', flow: lastSent, uriPath: lastUriPath }, '*')
if (isInIframe) {
// VS Code extension: round-trip via postMessage
window?.parent.postMessage({ type: 'flow', flow: lastSent, uriPath: lastUriPath }, '*')
} else if (socket && socket.readyState === WebSocket.OPEN) {
// CLI dev mode: round-trip via WebSocket
socket.send(JSON.stringify({ type: 'flow', flow: lastSent, uriPath: lastUriPath }))
}
}
}
@@ -846,7 +873,11 @@
</div>
{:else}
<!-- <div class="h-full w-full grid grid-cols-2"> -->
<div class="h-full w-full">
<div
class="h-full w-full"
bind:clientWidth={flowContainerWidth}
bind:clientHeight={flowContainerHeight}
>
<div class="flex flex-col max-h-screen h-full relative">
<div class="absolute top-0 left-2">
<DarkModeToggle bind:darkMode bind:this={darkModeToggle} forcedDarkMode={false} />
@@ -857,48 +888,51 @@
{/if}
</div>
<div class="flex justify-center pt-1 z-50 absolute -translate-x-[100%] right-2 top-2 gap-2">
<FlowPreviewButtons
{suspendStatus}
bind:this={flowPreviewButtons}
{onJobDone}
bind:localModuleStates
onRunPreview={() => {
showJobStatus = true
}}
/>
</div>
<Splitpanes horizontal class="h-full max-h-screen grow">
<Splitpanes horizontal={flowHorizontalSplit} class="h-full max-h-screen grow">
<Pane size={67}>
{#if flowStore.val?.value?.modules}
<div id="flow-editor"></div>
<FlowModuleSchemaMap
bind:this={flowModuleSchemaMap}
disableAi
disableTutorials
smallErrorHandler={true}
disableStaticInputs
localModuleStates={showJobStatus ? localModuleStates : {}}
onTestUpTo={flowPreviewButtons?.testUpTo}
testModuleStates={modulesTestStates}
isOwner={flowPreviewContent?.getIsOwner?.()}
onTestFlow={flowPreviewButtons?.runPreview}
isRunning={flowPreviewContent?.getIsRunning?.()}
onCancelTestFlow={flowPreviewContent?.cancelTest}
onOpenPreview={flowPreviewButtons?.openPreview}
onHideJobStatus={resetModulesStates}
flowJob={job}
{showJobStatus}
onDelete={(id) => {
delete localModuleStates[id]
delete modulesTestStates.states[id]
}}
{flowHasChanged}
/>
{:else}
<div class="text-red-400 mt-20">Missing flow modules</div>
{/if}
</Pane>
<div class="relative h-full w-full">
{#if flowStore.val?.value?.modules}
<div id="flow-editor"></div>
<div class="flex justify-center pt-1 z-50 absolute right-2 top-2 gap-2">
<FlowPreviewButtons
{suspendStatus}
bind:this={flowPreviewButtons}
{onJobDone}
bind:localModuleStates
onRunPreview={() => {
showJobStatus = true
}}
/>
</div>
<FlowModuleSchemaMap
bind:this={flowModuleSchemaMap}
disableAi
disableTutorials
smallErrorHandler={true}
disableStaticInputs
localModuleStates={showJobStatus ? localModuleStates : {}}
onTestUpTo={flowPreviewButtons?.testUpTo}
testModuleStates={modulesTestStates}
isOwner={flowPreviewContent?.getIsOwner?.()}
onTestFlow={flowPreviewButtons?.runPreview}
isRunning={flowPreviewContent?.getIsRunning?.()}
onCancelTestFlow={flowPreviewContent?.cancelTest}
onOpenPreview={flowPreviewButtons?.openPreview}
onHideJobStatus={resetModulesStates}
flowJob={job}
{showJobStatus}
onDelete={(id) => {
delete localModuleStates[id]
delete modulesTestStates.states[id]
}}
{flowHasChanged}
controlsPosition="bottom"
/>
{:else}
<div class="text-red-400 mt-20">Missing flow modules</div>
{/if}
</div></Pane
>
<Pane size={33}>
{#key reload}
<FlowEditorPanel

View File

@@ -813,7 +813,11 @@
</div>
{/if}
<div class="min-h-0 flex-grow" id="flow-editor-editor">
<div
class="min-h-0 flex-grow"
id="flow-editor-editor"
class:no-splitter={flowModule.value.type === 'aiagent' || noEditor}
>
<Splitpanes horizontal>
{#if flowModule.value.type !== 'aiagent'}
<Pane bind:size={editorPanelSize} minSize={10} class="relative">
@@ -1161,7 +1165,9 @@
<Section label="Continue on error">
{#snippet header()}
<Tooltip>
When enabled, the flow will continue to the next step even if this step fails (after exhausting all retries, if any). This enables to process the error in a branch one for instance.
When enabled, the flow will continue to the next step even if this
step fails (after exhausting all retries, if any). This enables to
process the error in a branch one for instance.
</Tooltip>
{/snippet}
<Toggle
@@ -1522,3 +1528,9 @@
<Button size="sm" on:click={confirmDebugBetaWarning}>Continue</Button>
{/snippet}
</Modal>
<style>
.no-splitter :global(.splitpanes__splitter) {
display: none;
}
</style>

View File

@@ -95,6 +95,7 @@
suspendStatus?: StateStore<Record<string, { job: Job; nb: number }>>
onDelete?: (id: string) => void
flowHasChanged?: boolean
controlsPosition?: 'top' | 'bottom'
}
let {
@@ -124,6 +125,7 @@
showJobStatus = false,
suspendStatus = $bindable({ val: {} }),
onDelete,
controlsPosition = 'top',
flowHasChanged
}: Props = $props()
@@ -620,7 +622,7 @@
/>
</div>
<div class="z-10 flex-auto grow bg-surface-secondary" bind:clientHeight={minHeight}>
<div class="z-10 flex-auto grow min-h-0 bg-surface-secondary" bind:clientHeight={minHeight}>
<FlowGraphV2
bind:this={graph}
earlyStop={flowStore.val.value?.skip_expr !== undefined}
@@ -1044,6 +1046,7 @@
{onCancelTestFlow}
{onOpenPreview}
{onHideJobStatus}
{controlsPosition}
exitNoteMode={() => (noteMode = false)}
onNotePositionUpdate={(noteId, position) => {
// Update note position via NoteEditor context in edit mode

View File

@@ -198,6 +198,7 @@
diffBeforeFlow?: OpenFlow
currentInputSchema?: Record<string, any>
markRemovedAsShadowed?: boolean
controlsPosition?: 'top' | 'bottom'
}
let {
@@ -269,7 +270,8 @@
onDeleteMultiple = undefined,
onDuplicateMultiple = undefined,
onMoveMultiple = undefined,
movingIds = undefined
movingIds = undefined,
controlsPosition = 'top'
}: Props = $props()
// Initialize note manager with fine-grained reactivity
@@ -738,7 +740,8 @@
} else {
const minY = Math.min(...nodes.map((n) => n.position.y))
const maxBottom = Math.max(...nodes.map((n) => n.position.y + NODE.height + 100))
height = Math.max(maxBottom - minY, minHeight)
const computed = maxBottom - minY
height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight)
}
}
@@ -1164,7 +1167,7 @@
</div>
{:else}
<Controls
position="top-right"
position={controlsPosition === 'bottom' ? 'bottom-right' : 'top-right'}
orientation="horizontal"
showLock={false}
fitViewOptions={{ nodes: nodes.filter((n) => n.type !== 'note') }}
@@ -1227,7 +1230,7 @@
</Controls>
<Controls
position="top-left"
position={controlsPosition === 'bottom' ? 'bottom-left' : 'top-left'}
orientation="vertical"
showLock={false}
showZoom={false}

View File

@@ -57,6 +57,8 @@ Launch a dev server that will spawn a webserver with HMR
**Options:**
- `--includes <pattern...:string>` - Filter paths givena glob pattern or path
- `--proxy-port <port:number>` - Port for a localhost reverse proxy to the remote Windmill server
- `--path <path:string>` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)
### docs

View File

@@ -1584,6 +1584,8 @@ Launch a dev server that will spawn a webserver with HMR
**Options:**
- \`--includes <pattern...:string>\` - Filter paths givena glob pattern or path
- \`--proxy-port <port:number>\` - Port for a localhost reverse proxy to the remote Windmill server
- \`--path <path:string>\` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)
### docs

View File

@@ -62,6 +62,8 @@ Launch a dev server that will spawn a webserver with HMR
**Options:**
- `--includes <pattern...:string>` - Filter paths givena glob pattern or path
- `--proxy-port <port:number>` - Port for a localhost reverse proxy to the remote Windmill server
- `--path <path:string>` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)
### docs