Files
windmill/cli/src/commands/app/dev.ts
T
Ruben Fiszel 4fedfdfd11 feat(cli): add consistent get/list/new subcommands for all item types (#8047)
* feat(cli): add consistent get/list/new subcommands for all item types

Make the CLI consistent so every item type (script, flow, app, resource,
resource-type, variable, schedule, folder, trigger) supports get/list/new
subcommands, enabling the CLI to be used as a full API client in bash
scripts with jq piping.

- Add --json flag to all list commands for machine-readable output
- Register explicit "list" subcommand alongside default action
- Add "get <path> [--json]" subcommand to fetch single items from API
- Rename "bootstrap" to "new" for script/flow, keep "bootstrap" as alias
- Add "new" subcommand for resource, resource-type, variable, schedule,
  folder, and trigger to create local template YAML files
- Update cli-commands skill documentation for wmill init
- Add integration tests for all new commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* all

* feat: install wmill CLI in Docker images and use it for bash variable/resource access

- Install windmill-cli via bun in all Dockerfiles that include bun
- DockerfileCli: switch from node:slim to oven/bun:slim
- CLI: auto-configure from WM_WORKSPACE/WM_TOKEN/BASE_INTERNAL_URL env vars
  as last-resort fallback when no workspace is configured
- Frontend: replace curl-based bash snippets with wmill variable/resource get
- Add backend integration tests for wmill CLI in bash scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): install windmill-cli in backend test workflow

Ensures wmill is available on PATH for bash integration tests
that use `wmill variable get` and `wmill resource get`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(cli): replace @std/* Deno dependencies with Node.js equivalents

Replace @std/log with a lightweight custom logger (core/log.ts),
@std/path with node:path, and @std/yaml with the yaml npm package.
Also fix process hang on exit, add --node option to install_dev.sh,
and add missing hasRequiredPermissions to NpmProvider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* all

* all

* all

* refactor(cli): replace @ayonli/jsext and @std/encoding with lightweight alternatives

Replace @ayonli/jsext (8.4MB) with tar-stream (32kB) for tar creation,
replace @std/encoding with Node.js Buffer.toString("hex"), and fix
@windmill-labs/shared-utils to use direct npm instead of JSR mirror.
Also resolve merge conflicts in sync.ts and fix pre-existing type errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cli): use singleQuote YAML output and pass yamlOptions in gitsync pull

The yaml library defaults to double quotes, but the codebase (and tests)
expect single-quoted strings. Add singleQuote: true to yamlOptions and
pass yamlOptions to gitsync-settings pull writeFile calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* all

* all

* fix(cli): address code review feedback

- Install CLI from source in backend tests instead of npm
- Fix script bootstrap catch block to re-throw "File already exists"
- Add type-safe local variable after trigger kind validation
- Use created_by instead of policy.on_behalf_of for app get output
- Note --kind is recommended for faster trigger lookup in help text
- Document node symlink purpose in Dockerfiles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): use /usr/bin for wmill wrapper to ensure it's in PATH

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): install wmill to ~/.local/bin to avoid permission issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci(backend): switch to Blacksmith runner and add cargo caching

- Switch from ubicloud-standard-16 to blacksmith-16vcpu-ubuntu-2404 for faster NVMe-backed builds
- Add stickydisk for cargo target directory (persistent NVMe cache across runs)
- Add cache for cargo registry and git dependencies
- Upgrade DuckDB FFI cache from actions/cache@v3 to useblacksmith/cache@v1
- Enable CARGO_INCREMENTAL=1 to benefit from persistent target cache

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix ci

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:53:28 +00:00

1675 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Command } from "@cliffy/command";
import { colors } from "@cliffy/ansi/colors";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import * as windmillUtils from "@windmill-labs/shared-utils";
import { yamlParseFile } from "../../utils/yaml.ts";
import * as getPort from "get-port";
import * as open from "open";
import { GlobalOptions } from "../../types.ts";
import * as http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import process from "node:process";
import { Buffer } from "node:buffer";
import { writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { WebSocket, WebSocketServer } from "ws";
import {
createFrameworkPlugins,
detectFrameworks,
ensureNodeModules,
getDevBuildOptions,
} from "./bundle.ts";
import { wmillTsDev as wmillTs } from "./wmillTsDev.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { requireLogin } from "../../core/auth.ts";
import { GLOBAL_CONFIG_OPT } from "../../core/conf.ts";
import { replaceInlineScripts, repopulateFields } from "./app.ts";
import { Runnable } from "./metadata.ts";
import {
APP_BACKEND_FOLDER,
inferRunnableSchemaFromFile,
} from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
import { regenerateAgentDocs } from "./generate_agents.ts";
import {
getFolderSuffix,
hasFolderSuffix,
loadNonDottedPathsSetting,
} from "../../utils/resource_folders.ts";
const DEFAULT_PORT = 4000;
const DEFAULT_HOST = "localhost";
// HTML template with live reload and SQL migration modal
const createHTML = (jsPath: string, cssPath: string) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Windmill App Dev Preview</title>
<link rel="stylesheet" href="${cssPath}">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100%;
height: 100vh;
}
/* SQL Migration Modal Styles */
.sql-modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
justify-content: center;
align-items: center;
}
.sql-modal-overlay.visible {
display: flex;
}
.sql-modal {
background: white;
border-radius: 8px;
max-width: 700px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.sql-modal-header {
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.sql-modal-header h2 {
margin: 0;
font-size: 18px;
color: #333;
}
.sql-modal-header .close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.sql-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.sql-modal-body p {
margin-bottom: 12px;
color: #555;
}
.sql-modal-body pre {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
font-size: 13px;
max-height: 300px;
overflow-y: auto;
}
.sql-modal-footer {
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.sql-modal-footer button {
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.sql-modal-footer .cancel-btn {
background: #f0f0f0;
border: 1px solid #ccc;
color: #333;
}
.sql-modal-footer .apply-btn {
background: #3b82f6;
border: none;
color: white;
}
.sql-modal-footer .apply-btn:hover {
background: #2563eb;
}
.sql-modal-footer .apply-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.sql-result {
margin-top: 12px;
padding: 12px;
border-radius: 4px;
}
.sql-result.success {
background: #dcfce7;
color: #166534;
}
.sql-result.error {
background: #fee2e2;
color: #991b1b;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- SQL Migration Modal -->
<div id="sql-modal-overlay" class="sql-modal-overlay">
<div class="sql-modal">
<div class="sql-modal-header">
<h2>Apply SQL Migration</h2>
<button class="close-btn" onclick="closeSqlModal()">&times;</button>
</div>
<div class="sql-modal-body">
<p><strong>File:</strong> <span id="sql-file-name"></span></p>
<p><strong>Datatable:</strong> <span id="sql-datatable"></span></p>
<p><strong>SQL to execute:</strong></p>
<pre id="sql-content"></pre>
<div id="sql-result"></div>
</div>
<div class="sql-modal-footer">
<button class="cancel-btn" onclick="closeSqlModal()">Cancel</button>
<button class="apply-btn" id="apply-sql-btn" onclick="applySql()">Apply SQL</button>
</div>
</div>
</div>
<script src="${jsPath}"></script>
<script>
// Live reload via EventSource
const evtSource = new EventSource('/__events');
evtSource.addEventListener('change', () => {
console.log('🔄 Files changed, reloading...');
location.reload();
});
evtSource.addEventListener('error', () => {
console.log('⚠️ Lost connection to dev server, retrying...');
});
// SQL Migration Modal handling
let pendingSqlMigration = null;
let sqlWebSocket = null;
let closeModalTimeout = null;
function initSqlWebSocket() {
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
sqlWebSocket = new WebSocket(wsProtocol + '//' + location.host);
sqlWebSocket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'sqlMigration') {
showSqlModal(data);
} else if (data.type === 'sqlMigrationResult') {
showSqlResult(data);
}
};
sqlWebSocket.onclose = () => {
// Reconnect after a delay
setTimeout(initSqlWebSocket, 1000);
};
}
function showSqlModal(data) {
// Cancel any pending close from a previous migration's success
if (closeModalTimeout) {
clearTimeout(closeModalTimeout);
closeModalTimeout = null;
}
pendingSqlMigration = data;
document.getElementById('sql-file-name').textContent = data.fileName;
document.getElementById('sql-datatable').textContent = data.datatable || 'Not configured';
document.getElementById('sql-content').textContent = data.sql;
document.getElementById('sql-result').innerHTML = '';
document.getElementById('sql-result').className = 'sql-result';
document.getElementById('apply-sql-btn').disabled = !data.datatable;
document.getElementById('apply-sql-btn').textContent = 'Apply SQL';
document.getElementById('sql-modal-overlay').classList.add('visible');
}
function closeSqlModal(skip = true) {
if (skip && pendingSqlMigration && sqlWebSocket) {
// Notify server that user skipped this file
sqlWebSocket.send(JSON.stringify({
type: 'skipSqlMigration',
fileName: pendingSqlMigration.fileName
}));
}
document.getElementById('sql-modal-overlay').classList.remove('visible');
pendingSqlMigration = null;
}
function applySql() {
if (!pendingSqlMigration || !sqlWebSocket) return;
document.getElementById('apply-sql-btn').disabled = true;
document.getElementById('apply-sql-btn').textContent = 'Applying...';
sqlWebSocket.send(JSON.stringify({
type: 'applySqlMigration',
fileName: pendingSqlMigration.fileName,
sql: pendingSqlMigration.sql,
datatable: pendingSqlMigration.datatable
}));
}
function showSqlResult(data) {
const resultDiv = document.getElementById('sql-result');
if (data.error) {
resultDiv.className = 'sql-result error';
resultDiv.innerHTML = '<strong>Error:</strong> ' + escapeHtml(data.message);
document.getElementById('apply-sql-btn').disabled = false;
document.getElementById('apply-sql-btn').textContent = 'Apply SQL';
} else {
resultDiv.className = 'sql-result success';
resultDiv.innerHTML = '<strong>Success!</strong> SQL applied and file deleted.';
// Auto-close after success (don't send skip since server already handled it)
// Save timeout ID so it can be cancelled if a new migration arrives
closeModalTimeout = setTimeout(() => {
closeModalTimeout = null;
closeSqlModal(false);
}, 1500);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize WebSocket for SQL migrations
initSqlWebSocket();
</script>
</body>
</html>
`;
interface DevOptions extends GlobalOptions {
port?: number;
host?: string;
entry?: string;
open?: boolean;
}
async function dev(opts: DevOptions, appFolder?: string) {
GLOBAL_CONFIG_OPT.noCdToRoot = true;
// Search for wmill.yaml by traversing upward (without git root constraint)
// to initialize nonDottedPaths setting before using folder suffix functions
await loadNonDottedPathsSetting();
// Resolve target directory from argument or use current directory
const originalCwd = process.cwd();
let targetDir = originalCwd;
if (appFolder) {
targetDir = path.isAbsolute(appFolder)
? appFolder
: path.join(originalCwd, appFolder);
if (!fs.existsSync(targetDir)) {
log.error(colors.red(`Error: Directory not found: ${targetDir}`));
process.exit(1);
}
}
// Validate that target is a .raw_app folder
const targetDirName = path.basename(targetDir);
if (!hasFolderSuffix(targetDirName, "raw_app")) {
log.error(
colors.red(
`Error: The dev command must be run inside a ${
getFolderSuffix("raw_app")
} folder.\n` +
`Target directory: ${targetDirName}\n` +
`Please navigate to a folder ending with '${
getFolderSuffix("raw_app")
}' or specify one as argument.`,
),
);
process.exit(1);
}
// Check for raw_app.yaml in target directory
const rawAppPath = path.join(targetDir, "raw_app.yaml");
if (!fs.existsSync(rawAppPath)) {
log.error(
colors.red(
`Error: raw_app.yaml not found in ${targetDir}.\n` +
`The dev command requires a ${
getFolderSuffix("raw_app")
} folder containing a raw_app.yaml file.`,
),
);
process.exit(1);
}
// Resolve workspace and authenticate (from original cwd to find wmill.yaml)
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const workspaceId = workspace.workspaceId;
// Change to target directory for the rest of the command
if (appFolder) {
process.chdir(targetDir);
}
// Load app path from raw_app.yaml
const rawApp = (await yamlParseFile(rawAppPath)) as any;
const appPath = rawApp?.custom_path ?? "u/unknown/newapp";
// Dynamically import esbuild only when the dev command is called
const esbuild = await import("esbuild");
const port = opts.port ??
(await getPort.default({
port: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((p) => p + DEFAULT_PORT),
}));
const host = opts.host ?? DEFAULT_HOST;
const shouldOpen = opts.open ?? true;
// Detect frameworks to determine default entry point
const frameworks = detectFrameworks(process.cwd());
const defaultEntry = frameworks.svelte || frameworks.vue
? "index.ts"
: "index.tsx";
const entryPoint = opts.entry ?? defaultEntry;
// Verify entry point exists
if (!fs.existsSync(entryPoint)) {
log.error(
colors.red(
`Entry point "${entryPoint}" not found. Please specify a valid entry point with --entry.`,
),
);
process.exit(1);
}
// Ensure node_modules exists
const appDir = path.dirname(entryPoint) || process.cwd();
await ensureNodeModules(appDir);
// In-memory cache of inferred schemas (runnableId -> schema)
// Used to generate wmill.d.ts without modifying raw_app.yaml
const inferredSchemas: Record<string, any> = {};
genRunnablesTs(inferredSchemas);
// Ensure dist directory exists
const distDir = path.join(process.cwd(), "dist");
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir);
}
// SSE clients for live reload
const clients: http.ServerResponse[] = [];
function notifyClients() {
clients.forEach((client) => {
client.write(`event: change\ndata: reload\n\n`);
});
}
const buildOptions = getDevBuildOptions(entryPoint);
// Load framework-specific plugins (svelte, vue) based on package.json
const frameworkPlugins = await createFrameworkPlugins(appDir);
const wmillPlugin = {
name: "wmill-virtual",
setup(build: any) {
// Intercept imports of wmill with various path formats:
// - wmill, wmill.ts (bare import)
// - /wmill, /wmill.ts (absolute)
// - ./wmill, ./wmill.ts (same directory)
// - ../wmill, ../../wmill, etc. (parent directories)
build.onResolve(
{ filter: /^(\.\.\/)+wmill(\.ts)?$|^(\.\/|\/)?wmill(\.ts)?$/ },
(args: any) => {
log.info(colors.yellow(`[wmill-virtual] Intercepted: ${args.path}`));
return {
path: args.path,
namespace: "wmill-virtual",
};
},
);
// Provide the virtual module content
build.onLoad(
{ filter: /.*/, namespace: "wmill-virtual" },
(args: any) => {
const contents = wmillTs(port);
log.info(
colors.yellow(
`[wmill-virtual] Loading virtual module: ${args.path}`,
),
);
log.info(
colors.gray(
`[wmill-virtual] Exports: ${
contents.match(/export (const|function) \w+/g)?.join(", ") ??
"none"
}`,
),
);
return {
contents,
loader: "ts",
};
},
);
},
};
// Create esbuild context
const ctx = await esbuild.context({
...buildOptions,
plugins: [
...frameworkPlugins,
{
name: "notify-on-rebuild",
setup(build: any) {
build.onEnd((result: any) => {
if (result.errors.length === 0) {
log.info(
colors.green("✅ Build succeeded, notifying clients..."),
);
notifyClients();
} else {
log.error(colors.red("❌ Build failed:"));
result.errors.forEach((error: any) => {
log.error(colors.red(error.text));
});
}
});
},
},
wmillPlugin,
],
});
// Start watching
await ctx.watch();
log.info(colors.blue("👀 Watching for file changes...\n"));
// Initial build
await ctx.rebuild();
// Watch runnables folder for changes
const runnablesPath = path.join(process.cwd(), APP_BACKEND_FOLDER);
let runnablesWatcher: fs.FSWatcher | undefined;
if (fs.existsSync(runnablesPath)) {
log.info(
colors.blue(`👁️ Watching runnables folder at: ${runnablesPath}\n`),
);
runnablesWatcher = fs.watch(runnablesPath, { recursive: true });
// Per-file debounce timeouts for schema inference (longer debounce for typing)
const schemaInferenceTimeouts: Record<string, ReturnType<typeof setTimeout>> = {};
const SCHEMA_DEBOUNCE_MS = 500; // Wait 500ms after last change before inferring schema
// Handle runnables file changes via callback
runnablesWatcher.on("change", (_eventType, filename) => {
if (!filename) return;
const fileStr = typeof filename === "string" ? filename : filename.toString();
const changedPath = path.join(runnablesPath, fileStr);
const relativePath = path.relative(process.cwd(), changedPath);
const relativeToRunnables = fileStr;
// Skip lock files
if (changedPath.endsWith(".lock")) {
return;
}
// Log the change event
log.info(
colors.cyan(
`📝 Runnable changed [${_eventType}]: ${relativePath}`,
),
);
// Debounce schema inference per file (wait for typing to finish)
if (schemaInferenceTimeouts[changedPath]) {
clearTimeout(schemaInferenceTimeouts[changedPath]);
}
schemaInferenceTimeouts[changedPath] = setTimeout(async () => {
delete schemaInferenceTimeouts[changedPath];
try {
log.info(
colors.cyan(
`📝 Inferring schema for: ${relativeToRunnables}`,
),
);
// Infer schema for this runnable (returns schema in memory, doesn't write to file)
const result = await inferRunnableSchemaFromFile(
process.cwd(),
relativeToRunnables,
);
if (result) {
// Store inferred schema in memory
inferredSchemas[result.runnableId] = result.schema;
log.info(
colors.green(
` Inferred Schemas: ${
JSON.stringify(
inferredSchemas,
null,
2,
)
}`,
),
);
// Regenerate wmill.d.ts with updated schema from memory
await genRunnablesTs(inferredSchemas);
}
} catch (error: any) {
log.error(
colors.red(`Error inferring schema: ${error.message}`),
);
}
}, SCHEMA_DEBOUNCE_MS);
});
runnablesWatcher.on("error", (error: Error) => {
log.error(colors.red(`Error watching runnables: ${error.message}`));
});
} else {
log.info(
colors.gray(
"️ No runnables folder found (will not watch for runnable changes)\n",
),
);
}
// Create HTTP server
const server = http.createServer((req, res) => {
const url = req.url || "/";
// SSE endpoint for live reload
if (url === "/__events") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write("data: connected\n\n");
clients.push(res);
req.on("close", () => {
const index = clients.indexOf(res);
if (index !== -1) clients.splice(index, 1);
});
return;
}
// Serve the bundled JS
if (url === "/dist/bundle.js" || url === "/bundle.js") {
const jsPath = path.join(process.cwd(), "dist/bundle.js");
if (fs.existsSync(jsPath)) {
res.writeHead(200, { "Content-Type": "application/javascript" });
res.end(fs.readFileSync(jsPath));
} else {
res.writeHead(404);
res.end("Bundle not found");
}
return;
}
// Serve the bundled CSS
if (url === "/dist/bundle.css" || url === "/bundle.css") {
const cssPath = path.join(process.cwd(), "dist/bundle.css");
if (fs.existsSync(cssPath)) {
res.writeHead(200, { "Content-Type": "text/css" });
res.end(fs.readFileSync(cssPath));
} else {
res.writeHead(404);
res.end("CSS not found");
}
return;
}
// Serve source maps
if (url === "/dist/bundle.js.map" || url === "/bundle.js.map") {
const mapPath = path.join(process.cwd(), "dist/bundle.js.map");
if (fs.existsSync(mapPath)) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(fs.readFileSync(mapPath));
} else {
res.writeHead(404);
res.end("Source map not found");
}
return;
}
if (url === "/dist/bundle.css.map" || url === "/bundle.css.map") {
const mapPath = path.join(process.cwd(), "dist/bundle.css.map");
if (fs.existsSync(mapPath)) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(fs.readFileSync(mapPath));
} else {
res.writeHead(404);
res.end("Source map not found");
}
return;
}
// Serve injected HTML for root and any other path
res.writeHead(200, { "Content-Type": "text/html" });
res.end(createHTML("/dist/bundle.js", "/dist/bundle.css"));
});
// Create WebSocket server on the same HTTP server
const wss = new WebSocketServer({ server });
// SQL migration state (defined here so it's accessible in WebSocket handler)
const sqlToApplyPath = path.join(process.cwd(), "sql_to_apply");
const sqlFileQueue: string[] = [];
let currentSqlFile: string | null = null;
// Helper to read datatable from raw_app.yaml
async function getDatatableConfig(): Promise<string | undefined> {
try {
const rawApp = (await yamlParseFile(
path.join(process.cwd(), "raw_app.yaml"),
)) as any;
return rawApp?.data?.datatable;
} catch {
return undefined;
}
}
// Helper to broadcast SQL migration notification to all WebSocket clients
function broadcastSqlMigration(
fileName: string,
sql: string,
datatable: string | undefined,
) {
const message = JSON.stringify({
type: "sqlMigration",
fileName,
sql,
datatable,
});
wss.clients.forEach((client: WebSocket) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// Helper to add a SQL file to the queue (if not already queued)
function queueSqlFile(filePath: string): void {
if (currentSqlFile === filePath) {
return; // Already being shown
}
if (sqlFileQueue.includes(filePath)) {
return; // Already in queue
}
sqlFileQueue.push(filePath);
}
// Helper to process the next SQL file in the queue
async function processNextSqlFile(): Promise<void> {
if (currentSqlFile !== null) {
// Already showing a file, wait for it to be processed
return;
}
if (sqlFileQueue.length === 0) {
return;
}
const filePath = sqlFileQueue.shift()!;
// Check if file still exists (might have been deleted)
if (!fs.existsSync(filePath)) {
log.info(
colors.gray(`File no longer exists: ${path.basename(filePath)}`),
);
// Try next file
await processNextSqlFile();
return;
}
currentSqlFile = filePath;
const fileName = path.basename(filePath);
try {
const sqlContent = await readFile(filePath, "utf-8");
if (!sqlContent.trim()) {
log.info(colors.gray(`Skipping empty file: ${fileName}`));
currentSqlFile = null;
await processNextSqlFile();
return;
}
const datatable = await getDatatableConfig();
log.info(
colors.magenta(
`📋 SQL file ready: ${fileName} (datatable: ${
datatable || "not configured"
}) [${sqlFileQueue.length} more in queue]`,
),
);
broadcastSqlMigration(fileName, sqlContent, datatable);
} catch (error: any) {
log.error(colors.red(`Error reading SQL file: ${error.message}`));
currentSqlFile = null;
await processNextSqlFile();
}
}
// Helper to handle SQL file completion (success or skip)
async function onSqlFileCompleted(
filePath: string,
deleteFile: boolean,
): Promise<void> {
if (deleteFile && fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
log.info(colors.green(`✓ Deleted: ${path.basename(filePath)}`));
} catch (error: any) {
log.error(
colors.red(
`Failed to delete ${path.basename(filePath)}: ${error.message}`,
),
);
}
}
currentSqlFile = null;
// Process next file in queue
await processNextSqlFile();
}
wss.on("connection", async (ws: WebSocket) => {
log.info(colors.cyan("[WebSocket] Client connected"));
// If there's a current SQL file being shown, send it to the new client
if (currentSqlFile && fs.existsSync(currentSqlFile)) {
try {
const sqlContent = await readFile(currentSqlFile, "utf-8");
const datatable = await getDatatableConfig();
const fileName = path.basename(currentSqlFile);
ws.send(JSON.stringify({
type: "sqlMigration",
fileName,
sql: sqlContent,
datatable,
}));
log.info(
colors.magenta(`📋 Sent pending SQL file to new client: ${fileName}`),
);
} catch {
// File might have been deleted, ignore
}
} else if (fs.existsSync(sqlToApplyPath)) {
// No current file, but check if there are files to process
const entries = fs.readdirSync(sqlToApplyPath);
const sqlFiles = entries.filter((entry: string) =>
entry.endsWith(".sql")
);
if (sqlFiles.length > 0 && sqlFileQueue.length === 0) {
// Queue and process files
for (const sqlFile of sqlFiles.sort()) {
queueSqlFile(path.join(sqlToApplyPath, sqlFile));
}
await processNextSqlFile();
}
}
ws.on("message", async (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
log.info(
colors.cyan(`[WebSocket] Received: ${JSON.stringify(message)}`),
);
const { type, reqId, runnable_id, v, jobId } = message;
// Helper to send response
const respond = (responseType: string, result: any, error: boolean) => {
ws.send(JSON.stringify({ type: responseType, reqId, result, error }));
};
// Helper to execute and wait for result
const runAndWaitForResult = async (
runnableId: string,
args: any,
): Promise<{ uuid: string; result: any }> => {
const runnables = await loadRunnables();
const runnable = runnables[runnableId];
if (!runnable) {
throw new Error(`Runnable not found: ${runnableId}`);
}
const uuid = await executeRunnable(
runnable,
workspaceId,
appPath,
runnableId,
args,
);
log.info(colors.gray(`[backend] Job started: ${uuid}`));
const result = await waitForJob(workspaceId, uuid);
return { uuid, result };
};
switch (type) {
case "backend": {
// Run a runnable synchronously and wait for result
log.info(colors.blue(`[backend] Running runnable: ${runnable_id}`));
try {
const { result } = await runAndWaitForResult(runnable_id, v);
respond("backendRes", result, false);
} catch (error: any) {
log.error(colors.red(`[backend] Error: ${error.message}`));
respond(
"backendRes",
{ message: error.message, stack: error.stack },
true,
);
}
break;
}
case "backendAsync": {
// Run a runnable asynchronously and return job ID immediately
log.info(
colors.blue(
`[backendAsync] Running runnable async: ${runnable_id}`,
),
);
try {
const runnables = await loadRunnables();
const runnable = runnables[runnable_id];
if (!runnable) {
throw new Error(`Runnable not found: ${runnable_id}`);
}
const uuid = await executeRunnable(
runnable,
workspaceId,
appPath,
runnable_id,
v,
);
log.info(colors.gray(`[backendAsync] Job started: ${uuid}`));
// Return job ID immediately
respond("backendAsyncRes", uuid, false);
// Wait for result in the background and send it when done
waitForJob(workspaceId, uuid)
.then((result) => {
respond("backendRes", result, false);
})
.catch((error: any) => {
respond(
"backendRes",
{ message: error.message, stack: error.stack },
true,
);
});
} catch (error: any) {
log.error(colors.red(`[backendAsync] Error: ${error.message}`));
respond(
"backendAsyncRes",
{ message: error.message, stack: error.stack },
true,
);
}
break;
}
case "waitJob": {
// Wait for a job to complete and return its result
log.info(colors.blue(`[waitJob] Waiting for job: ${jobId}`));
try {
const result = await waitForJob(workspaceId, jobId);
respond("backendRes", result, false);
} catch (error: any) {
log.error(colors.red(`[waitJob] Error: ${error.message}`));
respond(
"backendRes",
{ message: error.message, stack: error.stack },
true,
);
}
break;
}
case "getJob": {
// Get the current status/result of a job
log.info(colors.blue(`[getJob] Getting job status: ${jobId}`));
try {
const result = await getJobStatus(workspaceId, jobId);
respond("backendRes", result, false);
} catch (error: any) {
log.error(colors.red(`[getJob] Error: ${error.message}`));
respond(
"backendRes",
{ message: error.message, stack: error.stack },
true,
);
}
break;
}
case "streamJob": {
// Stream job results using SSE
log.info(colors.blue(`[streamJob] Streaming job: ${jobId}`));
try {
await streamJobWithSSE(
workspaceId,
jobId,
reqId,
ws,
workspace.remote,
workspace.token,
);
} catch (error: any) {
log.error(colors.red(`[streamJob] Error: ${error.message}`));
ws.send(
JSON.stringify({
type: "streamJobRes",
reqId,
error: true,
result: { message: error.message, stack: error.stack },
}),
);
}
break;
}
case "applySqlMigration": {
// Execute SQL migration against a datatable
const { sql, datatable, fileName } = message;
log.info(
colors.blue(
`[SQL Migration] Applying SQL from ${fileName} to datatable: ${datatable}`,
),
);
if (!datatable) {
ws.send(
JSON.stringify({
type: "sqlMigrationResult",
error: true,
message:
"No datatable configured. Set data.datatable in raw_app.yaml.",
}),
);
break;
}
try {
// Run SQL as a preview script against the datatable
const uuid = await wmill.runScriptPreview({
workspace: workspaceId,
requestBody: {
language: "postgresql",
content: sql,
args: { database: `datatable://${datatable}` },
},
});
log.info(
colors.gray(`[SQL Migration] Job started: ${uuid}`),
);
// Wait for the result
const result = await waitForJob(workspaceId, uuid);
log.info(
colors.green(`[SQL Migration] SQL applied successfully`),
);
ws.send(
JSON.stringify({
type: "sqlMigrationResult",
error: false,
result,
}),
);
// Delete the SQL file and process next in queue
if (currentSqlFile) {
await onSqlFileCompleted(currentSqlFile, true);
}
// Regenerate AGENTS.md and DATATABLES.md to reflect schema changes
try {
await regenerateAgentDocs(workspaceId, process.cwd(), true);
log.info(
colors.gray(
`[SQL Migration] Refreshed AGENTS.md and DATATABLES.md`,
),
);
} catch (regenError: any) {
log.warn(
colors.yellow(
`[SQL Migration] Could not refresh docs: ${regenError.message}`,
),
);
}
} catch (error: any) {
log.error(
colors.red(`[SQL Migration] Error: ${error.message}`),
);
ws.send(
JSON.stringify({
type: "sqlMigrationResult",
error: true,
message: error.message || String(error),
}),
);
// Don't delete file on error, but clear current so user can retry
currentSqlFile = null;
}
break;
}
case "skipSqlMigration": {
// User chose to skip this SQL file
const { fileName } = message;
log.info(
colors.yellow(`[SQL Migration] Skipped: ${fileName}`),
);
// Don't delete file, just move to next
if (currentSqlFile) {
await onSqlFileCompleted(currentSqlFile, false);
}
break;
}
default:
log.warn(
colors.yellow(`[WebSocket] Unknown message type: ${type}`),
);
respond(
"error",
{ message: `Unknown message type: ${type}` },
true,
);
}
} catch (error: any) {
log.error(
colors.red(`[WebSocket] Failed to parse message: ${error.message}`),
);
}
});
ws.on("close", () => {
log.info(colors.cyan("[WebSocket] Client disconnected"));
});
ws.on("error", (error: Error) => {
log.error(colors.red(`[WebSocket] Error: ${error.message}`));
});
});
// Watch sql_to_apply folder for SQL migration files
let sqlWatcher: fs.FSWatcher | undefined;
// Helper to scan for existing SQL files and add them to the queue
async function scanExistingSqlFiles(): Promise<void> {
if (!fs.existsSync(sqlToApplyPath)) {
return;
}
try {
const entries = fs.readdirSync(sqlToApplyPath);
const sqlFiles = entries
.filter((entry: string) => entry.endsWith(".sql"))
.sort(); // Sort alphabetically to process in order
if (sqlFiles.length === 0) {
return;
}
log.info(
colors.blue(
`🔍 Found ${sqlFiles.length} SQL file(s) in sql_to_apply/`,
),
);
// Add each SQL file to the queue
for (const sqlFile of sqlFiles) {
const filePath = path.join(sqlToApplyPath, sqlFile);
queueSqlFile(filePath);
}
// Start processing the queue
await processNextSqlFile();
} catch (error: any) {
log.error(
colors.red(`Error scanning sql_to_apply folder: ${error.message}`),
);
}
}
if (fs.existsSync(sqlToApplyPath)) {
log.info(
colors.blue(`🗃️ Watching sql_to_apply folder at: ${sqlToApplyPath}\n`),
);
sqlWatcher = fs.watch(sqlToApplyPath, { recursive: true });
// Debounce timeout for SQL file changes
const sqlDebounceTimeouts: Record<string, ReturnType<typeof setTimeout>> = {};
const SQL_DEBOUNCE_MS = 300;
// Handle SQL file changes via callback
sqlWatcher.on("change", (_eventType, filename) => {
if (!filename) return;
const fileStr = typeof filename === "string" ? filename : filename.toString();
const changedPath = path.join(sqlToApplyPath, fileStr);
// Only handle .sql files
if (!changedPath.endsWith(".sql")) {
return;
}
const fileName = path.basename(changedPath);
// Debounce per file
if (sqlDebounceTimeouts[changedPath]) {
clearTimeout(sqlDebounceTimeouts[changedPath]);
}
sqlDebounceTimeouts[changedPath] = setTimeout(async () => {
delete sqlDebounceTimeouts[changedPath];
log.info(colors.cyan(`📋 SQL file detected: ${fileName}`));
// Add to queue and process
queueSqlFile(changedPath);
await processNextSqlFile();
}, SQL_DEBOUNCE_MS);
});
sqlWatcher.on("error", (error: Error) => {
log.error(
colors.red(`Error watching sql_to_apply: ${error.message}`),
);
});
// Scan for existing SQL files after a delay (to let WebSocket clients connect)
setTimeout(() => {
scanExistingSqlFiles();
}, 2000);
} else {
log.info(
colors.gray(
"️ No sql_to_apply folder found (will not watch for SQL migrations)\n",
),
);
}
server.listen(port, host, () => {
const url = `http://${host}:${port}`;
log.info(colors.bold.green(`🚀 Dev server running at ${url}`));
log.info(
colors.cyan(`🔌 WebSocket server running at ws://${host}:${port}`),
);
log.info(colors.gray(`📦 Serving files from: ${process.cwd()}`));
log.info(colors.gray(`🔄 Live reload enabled\n`));
// Open browser if requested
if (shouldOpen) {
try {
open
.openApp(open.apps.browser, { arguments: [url] })
.catch((error: any) => {
log.error(
colors.yellow(
`Failed to open browser automatically: ${error.message}`,
),
);
});
log.info(colors.gray("Opened browser for you"));
} catch (error: any) {
log.error(colors.yellow(`Failed to open browser: ${error.message}`));
}
}
});
// Graceful shutdown
process.on("SIGINT", async () => {
log.info(colors.yellow("\n\n🛑 Shutting down..."));
clients.forEach((client) => client.end());
server.close();
// Close runnables watcher if it exists
if (runnablesWatcher) {
runnablesWatcher.close();
}
// Close SQL watcher if it exists
if (sqlWatcher) {
sqlWatcher.close();
}
await ctx.dispose();
process.exit(0);
});
}
const command = new Command()
.description(
"Start a development server for building apps with live reload and hot module replacement",
)
.arguments("[app_folder:string]")
.option(
"--port <port:number>",
"Port to run the dev server on (will find next available port if occupied)",
)
.option("--host <host:string>", "Host to bind the dev server to", {
default: DEFAULT_HOST,
})
.option(
"--entry <entry:string>",
"Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)",
)
.option("--no-open", "Don't automatically open the browser")
.action(dev as any);
export default command;
/**
* Generates wmill.d.ts with type definitions for runnables.
* Loads runnables from separate YAML files in the backend folder (new format)
* or falls back to raw_app.yaml (old format).
* Merges in-memory inferred schemas with runnables.
*
* @param schemaOverrides - In-memory schema overrides (runnableId -> schema)
*/
async function genRunnablesTs(schemaOverrides: Record<string, any> = {}) {
log.info(colors.blue("🔄 Generating wmill.d.ts..."));
const localPath = process.cwd();
const backendPath = path.join(localPath, APP_BACKEND_FOLDER);
// Load runnables from separate files (new format) or fall back to raw_app.yaml (old format)
let runnables = await loadRunnablesFromBackend(backendPath);
if (Object.keys(runnables).length === 0) {
// Fall back to old format
try {
const rawApp = (await yamlParseFile(
path.join(localPath, "raw_app.yaml"),
)) as any;
runnables = rawApp?.["runnables"] ?? {};
} catch {
runnables = {};
}
}
// Apply schema overrides from in-memory cache
if (Object.keys(schemaOverrides).length > 0) {
for (const [runnableId, schema] of Object.entries(schemaOverrides)) {
if (runnables[runnableId]?.inlineScript) {
runnables[runnableId].inlineScript.schema = schema;
runnables[runnableId].type = "inline";
}
}
}
try {
const newWmillTs = windmillUtils.genWmillTs(runnables);
writeFileSync(path.join(process.cwd(), "wmill.d.ts"), newWmillTs);
} catch (error: any) {
log.error(colors.red(`Failed to generate wmill.d.ts: ${error.message}`));
}
}
/**
* Convert runnables from file format to API format.
* File format uses type: "script"|"hubscript"|"flow" for path-based runnables.
* API format uses type: "path" with runType: "script"|"hubscript"|"flow".
*/
function convertRunnablesToApiFormat(runnables: Record<string, any>): void {
for (const [runnableId, runnable] of Object.entries(runnables)) {
if (
runnable?.type === "script" ||
runnable?.type === "hubscript" ||
runnable?.type === "flow"
) {
// Convert from file format to API format
// { type: "script" } -> { type: "path", runType: "script" }
const originalType = runnable.type;
runnable.runType = originalType;
runnable.type = "path";
log.debug(
`Converted runnable '${runnableId}' from type='${originalType}' to type='path', runType='${originalType}'`,
);
}
}
}
async function loadRunnables(): Promise<Record<string, Runnable>> {
try {
const localPath = process.cwd();
const backendPath = path.join(localPath, APP_BACKEND_FOLDER);
// Load runnables from separate files (new format) or fall back to raw_app.yaml (old format)
let runnables = await loadRunnablesFromBackend(backendPath);
if (Object.keys(runnables).length === 0) {
// Fall back to old format
const rawApp = (await yamlParseFile(
path.join(localPath, "raw_app.yaml"),
)) as any;
runnables = rawApp?.runnables ?? {};
}
// Always convert path-based runnables from file format to API format
// This handles both backend folder runnables and raw_app.yaml runnables
convertRunnablesToApiFormat(runnables);
replaceInlineScripts(runnables, backendPath + SEP, true);
repopulateFields(runnables);
return runnables;
} catch (error: any) {
log.error(colors.red(`Failed to load runnables: ${error.message}`));
return {};
}
}
async function executeRunnable(
runnable: Runnable,
workspace: string,
appPath: string,
runnableId: string,
args: any,
): Promise<string> {
const requestBody: any = {
component: runnableId,
args: args ?? {},
force_viewer_static_fields: {},
force_viewer_one_of_fields: {},
force_viewer_allow_user_resources: [],
};
// Handle fields (static, ctx, user)
if (runnable.fields) {
for (const [key, field] of Object.entries(runnable.fields)) {
if (field?.type === "static") {
requestBody.force_viewer_static_fields[key] = field.value;
} else if (field?.type === "ctx" && field?.ctx) {
// Convert ctx fields to $ctx:property format for backend resolution
requestBody.args[key] = `$ctx:${field.ctx}`;
}
if (field?.type === "user" && field?.allowUserResources) {
requestBody.force_viewer_allow_user_resources.push(key);
}
}
}
if (
(runnable.type === "inline" || runnable.type === "runnableByName") &&
runnable.inlineScript
) {
const inlineScript = runnable.inlineScript;
if (inlineScript.id !== undefined) {
requestBody.id = inlineScript.id;
}
requestBody.raw_code = {
content: inlineScript.id === undefined ? inlineScript.content : "",
language: inlineScript.language ?? "",
path: `${appPath}/${runnableId}`,
lock: inlineScript.id === undefined ? inlineScript.lock : undefined,
cache_ttl: inlineScript.cache_ttl,
};
} else if (
(runnable.type === "path" || runnable.type === "runnableByPath") &&
runnable.runType &&
runnable.path
) {
// Path-based runnables have type: "path" (or legacy "runnableByPath") and runType: "script"|"hubscript"|"flow"
const prefix = runnable.runType;
requestBody.path = prefix !== "hubscript"
? `${prefix}/${runnable.path}`
: `script/${runnable.path}`;
} else {
// Neither inline script nor valid path-based runnable
const debugInfo =
`type=${(runnable as any).type}, runType=${(runnable as any).runType}, ` +
`path=${(runnable as any).path}, hasInlineScript=${!!(runnable as any)
.inlineScript}`;
log.error(
colors.red(
`[executeRunnable] Invalid runnable configuration for '${runnableId}': ${debugInfo}`,
),
);
throw new Error(
`Invalid runnable '${runnableId}': ${debugInfo}. ` +
`Must have either inlineScript (for inline type) or type="path" with runType and path fields`,
);
}
const uuid = await wmill.executeComponent({
workspace,
path: appPath,
requestBody,
});
return uuid;
}
const ITERATIONS_BEFORE_SLOW_REFRESH = 10;
const ITERATIONS_BEFORE_SUPER_SLOW_REFRESH = 100;
async function waitForJob(workspace: string, jobId: string): Promise<any> {
if (!jobId) {
throw new Error("Job ID is required");
}
let syncIteration = 0;
return new Promise((resolve, reject) => {
async function checkJob() {
try {
const maybeJob = await wmill.getCompletedJobResultMaybe({
workspace,
id: jobId,
getStarted: false,
});
if (maybeJob.completed) {
if (
!maybeJob.success &&
typeof maybeJob.result === "object" &&
maybeJob.result !== null &&
"error" in maybeJob.result
) {
reject((maybeJob.result as any).error);
} else {
resolve(maybeJob.result);
}
return;
}
} catch (err: any) {
log.error(colors.red(`Error checking job ${jobId}: ${err.message}`));
}
syncIteration++;
let nextIteration = 50;
if (syncIteration > ITERATIONS_BEFORE_SUPER_SLOW_REFRESH) {
nextIteration = 2000;
} else if (syncIteration > ITERATIONS_BEFORE_SLOW_REFRESH) {
nextIteration = 500;
}
setTimeout(checkJob, nextIteration);
}
checkJob();
});
}
async function getJobStatus(workspace: string, jobId: string): Promise<any> {
return await wmill.getJob({
workspace,
id: jobId,
});
}
async function streamJobWithSSE(
workspace: string,
jobId: string,
reqId: string,
ws: WebSocket,
baseUrl: string,
token: string,
): Promise<void> {
const sseUrl =
`${baseUrl}api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?fast=true`;
const response = await fetch(sseUrl, {
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(
`SSE request failed: ${response.status} ${response.statusText}`,
);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body for SSE stream");
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
try {
const update = JSON.parse(data);
const type = update.type;
if (type === "ping" || type === "timeout") {
if (type === "timeout") {
reader.cancel();
return;
}
continue;
}
if (type === "error") {
ws.send(
JSON.stringify({
type: "streamJobRes",
reqId,
error: true,
result: { message: update.error || "SSE error" },
}),
);
reader.cancel();
return;
}
if (type === "not_found") {
ws.send(
JSON.stringify({
type: "streamJobRes",
reqId,
error: true,
result: { message: "Job not found" },
}),
);
reader.cancel();
return;
}
// Send stream update if there's new stream data
if (update.new_result_stream !== undefined) {
ws.send(
JSON.stringify({
type: "streamJobUpdate",
reqId,
new_result_stream: update.new_result_stream,
stream_offset: update.stream_offset,
}),
);
}
// Check if job is completed
if (update.completed) {
ws.send(
JSON.stringify({
type: "streamJobRes",
reqId,
error: false,
result: update.only_result,
}),
);
reader.cancel();
return;
}
} catch (parseErr) {
log.warn(`Failed to parse SSE data: ${parseErr}`);
}
}
}
}
} finally {
reader.releaseLock();
}
}