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>
This commit is contained in:
Guilhem Lemouel
2026-03-25 18:08:53 +01:00
co-authored by Claude Opus 4.6
parent d1ee68c376
commit a4ef96f056
3 changed files with 139 additions and 23 deletions
+56 -23
View File
@@ -28,12 +28,9 @@ import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../w
import { extractInlineScripts, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { parseMetadataFile } from "../../utils/metadata.ts";
import {
getFolderSuffix,
getFolderSuffixWithSep,
getMetadataFileName,
extractFolderPath,
getNonDottedPaths,
hasFolderSuffix,
loadNonDottedPathsSetting,
} from "../../utils/resource_folders.ts";
import * as path from "node:path";
@@ -122,7 +119,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
// Need to init nonDottedPaths before checking suffix
await loadNonDottedPathsSetting();
if (hasFolderSuffix(cwdBasename, "flow")) {
if (cwdBasename.endsWith(".flow") || cwdBasename.endsWith("__flow")) {
GLOBAL_CONFIG_OPT.noCdToRoot = true;
// Find workspace root
@@ -141,10 +138,14 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
if (workspaceRoot) {
const relPath = path.relative(workspaceRoot, cwd).replaceAll("\\", "/");
const flowSuffix = getFolderSuffix("flow");
opts.path = relPath.endsWith(flowSuffix)
? relPath.slice(0, -flowSuffix.length)
: relPath;
// 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);
@@ -189,12 +190,13 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
});
}
const flowFolderSuffix = getFolderSuffixWithSep("flow");
const flowMetadataFile = getMetadataFileName("flow", "yaml");
async function loadPaths(pathsToLoad: string[]) {
const paths = pathsToLoad.filter((path) =>
exts.some(
(ext) => path.endsWith(ext) || path.endsWith(flowFolderSuffix + flowMetadataFile)
(ext) => path.endsWith(ext)
|| path.endsWith(".flow/" + flowMetadataFile)
|| path.endsWith("__flow/" + flowMetadataFile)
)
);
if (paths.length == 0) {
@@ -203,10 +205,27 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
const nativePath = (await realpath(paths[0])).replace(base + SEP, "");
const cpath = nativePath.replaceAll("\\", "/");
if (!ignore(nativePath, false)) {
const typ = getTypeStrFromPath(cpath);
let 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;
@@ -227,10 +246,13 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
});
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
tagReplacedPathScripts(localFlow);
const flowSuffix = getFolderSuffix("flow");
const wmFlowPath = localPath.endsWith(flowSuffix + "/")
? localPath.slice(0, -(flowSuffix.length + 1))
: localPath.replace(/\/$/, "");
// 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,
@@ -284,10 +306,12 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
// Normalize a windmill path by stripping any trailing flow/app suffix
function normalizeWmPath(p: string): string {
const flowSuffix = getFolderSuffix("flow");
let result = p.replace(/\/$/, "");
if (result.endsWith(flowSuffix)) {
result = result.slice(0, -flowSuffix.length);
// 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;
}
@@ -295,11 +319,20 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) {
// 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
const flowDir = wmPath + getFolderSuffix("flow") + "/";
const flowYaml = flowDir + "flow.yaml";
// 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 {
await access(flowYaml);
if (!flowDir || !flowYaml) throw new Error("not a flow");
const localFlow = (await yamlParseFile(flowYaml)) as FlowFile;
await replaceInlineScripts(
localFlow.value.modules,
+43
View File
@@ -399,6 +399,49 @@ async function initAction(opts: InitOptions) {
);
}
// 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);
+40
View File
@@ -2346,6 +2346,46 @@ export async function pull(
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(