mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
997dd6ac3a
commit
435de95e7d
@@ -0,0 +1,155 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import type { SyncCodebase } from "./codebase.ts";
|
||||
import { parseMetadataFileIfExists } from "./metadata.ts";
|
||||
import { inferContentTypeFromFilePath } from "./script_common.ts";
|
||||
import { findCodebase } from "../commands/sync/sync.ts";
|
||||
import type { LocalScriptInfo } from "../../windmill-utils-internal/src/inline-scripts/replacer.ts";
|
||||
import type { RawScript } from "../../../gen/types.gen.ts";
|
||||
|
||||
export class UnsupportedLocalPathScriptPreviewError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UnsupportedLocalPathScriptPreviewError";
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalLock(scriptPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(scriptPath + ".script.lock", "utf-8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptionalLock(lock: string | undefined): string | undefined {
|
||||
return typeof lock === "string" && lock.trim() === "" ? undefined : lock;
|
||||
}
|
||||
|
||||
async function bundleSingleFileCodebaseScript(
|
||||
filePath: string,
|
||||
codebase: SyncCodebase
|
||||
): Promise<string> {
|
||||
if (codebase.customBundler) {
|
||||
// Pass the script path as a positional shell argument so existing shell-based
|
||||
// custom bundlers still work without interpolating the path into the command.
|
||||
return execFileSync(
|
||||
"sh",
|
||||
["-lc", `${codebase.customBundler} "$1"`, "sh", filePath],
|
||||
{
|
||||
maxBuffer: 1024 * 1024 * 50,
|
||||
}
|
||||
).toString();
|
||||
}
|
||||
|
||||
const esbuild = await import("esbuild");
|
||||
const out = await esbuild.build({
|
||||
entryPoints: [filePath],
|
||||
// Inline rawscripts are executed through the standard module wrapper,
|
||||
// so the bundle must expose `main` as an ESM export.
|
||||
format: "esm",
|
||||
bundle: true,
|
||||
write: false,
|
||||
external: codebase.external,
|
||||
inject: codebase.inject,
|
||||
define: codebase.define,
|
||||
loader: codebase.loader ?? { ".node": "file" },
|
||||
outdir: "/",
|
||||
platform: "node",
|
||||
packages: "bundle",
|
||||
target: "esnext",
|
||||
banner: codebase.banner,
|
||||
});
|
||||
|
||||
if (out.outputFiles.length === 0) {
|
||||
throw new Error(`No output files found for ${filePath}`);
|
||||
}
|
||||
if (out.outputFiles.length > 1) {
|
||||
throw new UnsupportedLocalPathScriptPreviewError(
|
||||
`Local PathScript ${filePath} requires a multi-file bundle, which flow preview/dev cannot inline yet`
|
||||
);
|
||||
}
|
||||
if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
|
||||
throw new UnsupportedLocalPathScriptPreviewError(
|
||||
`Local PathScript ${filePath} requires codebase assets, which flow preview/dev cannot inline yet`
|
||||
);
|
||||
}
|
||||
|
||||
return out.outputFiles[0].text;
|
||||
}
|
||||
|
||||
export function createPreviewLocalScriptReader(opts: {
|
||||
exts: string[];
|
||||
defaultTs?: "bun" | "deno";
|
||||
codebases: SyncCodebase[];
|
||||
}): (scriptPath: string) => Promise<LocalScriptInfo | undefined> {
|
||||
return async (scriptPath) => {
|
||||
const localScript = await resolvePreviewLocalScriptState(scriptPath, opts);
|
||||
if (!localScript) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content = localScript.codebase
|
||||
? await bundleSingleFileCodebaseScript(localScript.filePath, localScript.codebase)
|
||||
: localScript.content;
|
||||
|
||||
return {
|
||||
content,
|
||||
language: localScript.language,
|
||||
lock: localScript.lock,
|
||||
tag: localScript.tag,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type PreviewLocalScriptState = {
|
||||
filePath: string;
|
||||
content: string;
|
||||
language: RawScript["language"];
|
||||
lock?: string;
|
||||
tag?: string;
|
||||
codebase?: SyncCodebase;
|
||||
codebaseDigest?: string;
|
||||
};
|
||||
|
||||
export async function resolvePreviewLocalScriptState(
|
||||
scriptPath: string,
|
||||
opts: {
|
||||
exts: string[];
|
||||
defaultTs?: "bun" | "deno";
|
||||
codebases: SyncCodebase[];
|
||||
}
|
||||
): Promise<PreviewLocalScriptState | undefined> {
|
||||
for (const ext of opts.exts) {
|
||||
const filePath = scriptPath + ext;
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await stat(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!fileStat.isFile()) continue;
|
||||
|
||||
const language = inferContentTypeFromFilePath(filePath, opts.defaultTs);
|
||||
const metadata = await parseMetadataFileIfExists(scriptPath);
|
||||
const rawLock = metadata?.payload?.lock ?? (await readOptionalLock(scriptPath));
|
||||
const codebase =
|
||||
language === "bun" ? findCodebase(filePath, opts.codebases) : undefined;
|
||||
|
||||
return {
|
||||
filePath,
|
||||
content: await readFile(filePath, "utf-8"),
|
||||
language,
|
||||
lock: normalizeOptionalLock(rawLock),
|
||||
tag: metadata?.payload?.tag,
|
||||
codebase,
|
||||
codebaseDigest: codebase
|
||||
? await codebase.getDigest(
|
||||
Array.isArray(codebase.assets) && codebase.assets.length > 0
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user