From a4ef96f0568a288ae3f381f5c455b3af2d74d512 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 25 Mar 2026 18:08:53 +0100 Subject: [PATCH] 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) --- cli/src/commands/dev/dev.ts | 79 +++++++++++++++++++++++++---------- cli/src/commands/init/init.ts | 43 +++++++++++++++++++ cli/src/commands/sync/sync.ts | 40 ++++++++++++++++++ 3 files changed, 139 insertions(+), 23 deletions(-) diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index 5b2486cd36..fef06442fa 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -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 { 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, diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index d350c68983..26d9ebfec7 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -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); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 7c20915259..5e0dcebe7a 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -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(