Skip to content

Commit 435de95

Browse files
centdixclaude
andauthored
feat(cli): use local scripts when previewing flows (#8365)
* feat(cli): use local scripts when previewing flows When previewing a flow, PathScript modules (type: "script") now resolve to local file content instead of remote versions. This ensures flow preview and dev mode test the actual local changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(cli): add tests for PathScript local replacement in flow preview Unit tests for replacePathScriptsWithLocal covering: - basic PathScript→RawScript conversion - tag_override preservation - missing local file fallback - mixed module types - nested structures (loops, branches) Integration test verifying flow preview with a PathScript step uses the local script file content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(cli): extract shared helpers and add aiagent support for PathScript replacement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(cli): replace `as any` casts with proper type assertions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): preserve local flow preview script context * fix(cli): normalize inline flow preview bundles for bun * fix(cli): make local flow path scripts opt-in * fix(cli): only merge flow preview config for local mode * chore(system-prompts): regenerate cli command guidance * fix(cli): skip deno defaultTs test in CI without deno runtime Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(cli): clean up local path script helpers * feat(cli): make flow preview use local path scripts * fix(cli): ignore normalized preview metadata drift * chore(cli): address review follow-ups * test(cli): cover custom bundler path quoting --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 997dd6a commit 435de95

File tree

11 files changed

+1142
-15
lines changed

11 files changed

+1142
-15
lines changed

cli/src/commands/dev/dev.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,34 @@ import { resolveWorkspace } from "../../core/context.ts";
1616
import {
1717
SyncOptions,
1818
mergeConfigWithConfigFile,
19-
readConfigFile,
2019
} from "../../core/conf.ts";
2120
import { exts, removeExtensionToPath } from "../script/script.ts";
2221
import { inferContentTypeFromFilePath } from "../../utils/script_common.ts";
2322
import { OpenFlow } from "../../../gen/types.gen.ts";
2423
import { FlowFile } from "../flow/flow.ts";
25-
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
24+
import { replaceInlineScripts, replaceAllPathScriptsWithLocal } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
2625
import { parseMetadataFile } from "../../utils/metadata.ts";
2726
import {
2827
getFolderSuffixWithSep,
2928
getMetadataFileName,
3029
extractFolderPath,
3130
} from "../../utils/resource_folders.ts";
31+
import { listSyncCodebases } from "../../utils/codebase.ts";
32+
import { createPreviewLocalScriptReader } from "../../utils/local_path_scripts.ts";
3233

3334
const PORT = 3001;
3435
async function dev(opts: GlobalOptions & SyncOptions) {
36+
opts = await mergeConfigWithConfigFile(opts);
3537
const workspace = await resolveWorkspace(opts);
3638
await requireLogin(opts);
3739

3840
log.info("Started dev mode");
39-
const conf = await readConfigFile();
4041
let currentLastEdit: LastEditScript | LastEditFlow | undefined = undefined;
4142

4243
const fsWatcher = watch(".", { recursive: true });
4344
const base = await realpath(".");
44-
opts = await mergeConfigWithConfigFile(opts);
4545
const ignore = await ignoreF(opts);
46+
const codebases = await listSyncCodebases(opts);
4647

4748
const changesTimeouts: Record<string, ReturnType<typeof setTimeout>> = {};
4849
function watchChanges() {
@@ -56,7 +57,11 @@ async function dev(opts: GlobalOptions & SyncOptions) {
5657
}
5758
changesTimeouts[key] = setTimeout(async () => {
5859
delete changesTimeouts[key];
59-
await loadPaths([filePath]);
60+
await loadPaths([filePath]).catch((error) => {
61+
log.error(
62+
`Failed to reload ${filePath}: ${error instanceof Error ? error.message : error}`
63+
);
64+
});
6065
}, 100);
6166
});
6267
fsWatcher.on("error", (err) => {
@@ -94,6 +99,13 @@ async function dev(opts: GlobalOptions & SyncOptions) {
9499
SEP,
95100
undefined,
96101
);
102+
// Replace PathScript modules with local file content so dev mode uses local versions
103+
const localScriptReader = createPreviewLocalScriptReader({
104+
exts,
105+
defaultTs: opts.defaultTs,
106+
codebases,
107+
});
108+
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
97109
currentLastEdit = {
98110
type: "flow",
99111
flow: localFlow,
@@ -105,7 +117,7 @@ async function dev(opts: GlobalOptions & SyncOptions) {
105117
const content = await readFile(cpath, "utf-8");
106118
const splitted = cpath.split(".");
107119
const wmPath = splitted[0];
108-
const lang = inferContentTypeFromFilePath(cpath, conf.defaultTs);
120+
const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs);
109121
const typed =
110122
(await parseMetadataFile(
111123
removeExtensionToPath(cpath),

cli/src/commands/flow/flow.ts

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,19 @@ import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts";
1818
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
1919
import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts";
2020
import { Flow } from "../../../gen/types.gen.ts";
21-
import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
21+
import {
22+
collectPathScriptPaths,
23+
replaceInlineScripts,
24+
replaceAllPathScriptsWithLocal,
25+
} from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts";
2226
import { generateFlowLockInternal } from "./flow_metadata.ts";
27+
import { exts } from "../script/script.ts";
28+
import type { SyncCodebase } from "../../utils/codebase.ts";
29+
import { listSyncCodebases } from "../../utils/codebase.ts";
30+
import {
31+
createPreviewLocalScriptReader,
32+
resolvePreviewLocalScriptState,
33+
} from "../../utils/local_path_scripts.ts";
2334

2435
export interface FlowFile {
2536
summary: string;
@@ -28,6 +39,90 @@ export interface FlowFile {
2839
schema?: any;
2940
}
3041

42+
function normalizeOptionalString(value: string | null | undefined): string | undefined {
43+
return typeof value === "string" && value.trim() === "" ? undefined : value ?? undefined;
44+
}
45+
46+
function normalizeComparableContent(value: string | undefined): string | undefined {
47+
return value?.replaceAll("\r\n", "\n").replace(/\n$/, "");
48+
}
49+
50+
async function findDivergedLocalPathScripts(
51+
workspaceId: string,
52+
scriptPaths: string[],
53+
opts: {
54+
exts: string[];
55+
defaultTs?: "bun" | "deno";
56+
codebases: SyncCodebase[];
57+
}
58+
): Promise<{ changed: string[]; missing: string[] }> {
59+
const changed: string[] = [];
60+
const missing: string[] = [];
61+
62+
for (const scriptPath of scriptPaths) {
63+
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
64+
if (!localScript) {
65+
continue;
66+
}
67+
68+
let remoteScript;
69+
try {
70+
remoteScript = await wmill.getScriptByPath({
71+
workspace: workspaceId,
72+
path: scriptPath,
73+
});
74+
} catch {
75+
missing.push(scriptPath);
76+
continue;
77+
}
78+
79+
const remoteLock = normalizeOptionalString(remoteScript.lock);
80+
const diverged =
81+
normalizeComparableContent(localScript.content) !==
82+
normalizeComparableContent(remoteScript.content) ||
83+
localScript.language !== remoteScript.language ||
84+
(localScript.lock !== undefined &&
85+
normalizeComparableContent(localScript.lock) !==
86+
normalizeComparableContent(remoteLock)) ||
87+
localScript.tag !== normalizeOptionalString(remoteScript.tag) ||
88+
localScript.codebaseDigest !== normalizeOptionalString(remoteScript.codebase);
89+
90+
if (diverged) {
91+
changed.push(scriptPath);
92+
}
93+
}
94+
95+
return { changed, missing };
96+
}
97+
98+
function warnAboutLocalPathScriptDivergence(
99+
divergence: { changed: string[]; missing: string[] }
100+
): void {
101+
if (divergence.changed.length === 0 && divergence.missing.length === 0) {
102+
return;
103+
}
104+
105+
const details: string[] = [];
106+
if (divergence.changed.length > 0) {
107+
details.push(
108+
`These workspace scripts differ from the deployed version:\n${divergence.changed
109+
.map((path) => `- ${path}`)
110+
.join("\n")}`
111+
);
112+
}
113+
if (divergence.missing.length > 0) {
114+
details.push(
115+
`These scripts do not exist in the workspace yet:\n${divergence.missing
116+
.map((path) => `- ${path}`)
117+
.join("\n")}`
118+
);
119+
}
120+
121+
log.warn(
122+
`Using local PathScript files for flow preview.\n${details.join("\n")}\nUse --remote to preview deployed workspace scripts instead.`
123+
);
124+
}
125+
31126
const alreadySynced: string[] = [];
32127

33128
export async function pushFlow(
@@ -233,11 +328,17 @@ async function preview(
233328
opts: GlobalOptions & {
234329
data?: string;
235330
silent: boolean;
236-
},
331+
remote?: boolean;
332+
} & SyncOptions,
237333
flowPath: string
238334
) {
335+
const useLocalPathScripts = !opts.remote;
336+
if (useLocalPathScripts) {
337+
opts = await mergeConfigWithConfigFile(opts);
338+
}
239339
const workspace = await resolveWorkspace(opts);
240340
await requireLogin(opts);
341+
const codebases = useLocalPathScripts ? listSyncCodebases(opts) : [];
241342

242343
// Normalize path - ensure it's a directory path to a .flow folder
243344
if (!flowPath.endsWith(".flow") && !flowPath.endsWith(".flow" + SEP)) {
@@ -274,6 +375,31 @@ async function preview(
274375
await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP);
275376
}
276377

378+
if (useLocalPathScripts) {
379+
const scriptPaths = collectPathScriptPaths(localFlow.value);
380+
if (scriptPaths.length > 0) {
381+
const divergence = await findDivergedLocalPathScripts(
382+
workspace.workspaceId,
383+
scriptPaths,
384+
{
385+
exts,
386+
defaultTs: opts.defaultTs,
387+
codebases,
388+
}
389+
);
390+
if (!opts.silent) {
391+
warnAboutLocalPathScriptDivergence(divergence);
392+
}
393+
}
394+
395+
const localScriptReader = createPreviewLocalScriptReader({
396+
exts,
397+
defaultTs: opts.defaultTs,
398+
codebases,
399+
});
400+
await replaceAllPathScriptsWithLocal(localFlow.value, localScriptReader, log);
401+
}
402+
277403
const input = opts.data ? await resolve(opts.data) : {};
278404

279405
if (!opts.silent) {
@@ -444,7 +570,7 @@ const command = new Command()
444570
.action(run as any)
445571
.command(
446572
"preview",
447-
"preview a local flow without deploying it. Runs the flow definition from local files."
573+
"preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default."
448574
)
449575
.arguments("<flow_path:string>")
450576
.option(
@@ -455,6 +581,10 @@ const command = new Command()
455581
"-s --silent",
456582
"Do not output anything other then the final output. Useful for scripting."
457583
)
584+
.option(
585+
"--remote",
586+
"Use deployed workspace scripts for PathScript steps instead of local files."
587+
)
458588
.action(preview as any)
459589
.command(
460590
"generate-locks",

cli/src/guidance/skills.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5039,9 +5039,10 @@ flow related commands
50395039
- \`flow run <path:string>\` - run a flow by path.
50405040
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
50415041
- \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting.
5042-
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files.
5042+
- \`flow preview <flow_path:string>\` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.
50435043
- \`-d --data <data:string>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
50445044
- \`-s --silent\` - Do not output anything other then the final output. Useful for scripting.
5045+
- \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files.
50455046
- \`flow generate-locks [flow:file]\` - re-generate the lock files of all inline scripts of all updated flows
50465047
- \`--yes\` - Skip confirmation prompt
50475048
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)

0 commit comments

Comments
 (0)