feat(cli): add script preview and flow preview commands (#7729)

- Add `wmill script preview <path> [--data <json>]` command to test scripts against remote workspace without deploying
- Add `wmill flow preview <path> [--data <json>]` command to test flows against remote workspace without deploying
- Support codebase scripts with automatic bundling via esbuild
- Add `--silent` flag to suppress logs and only output final result
- Fix `findCodebase` to properly check if path is within codebase relative_path before pattern matching

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-01-28 19:52:43 +00:00
committed by GitHub
parent 42dc90ec09
commit 985ec901fe
3 changed files with 319 additions and 2 deletions

View File

@@ -1090,6 +1090,211 @@ async function generateMetadata(
}
}
async function preview(
opts: GlobalOptions & {
data?: string;
silent: boolean;
} & SyncOptions,
filePath: string
) {
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (!validatePath(filePath)) {
return;
}
const fstat = await Deno.stat(filePath);
if (!fstat.isFile) {
throw new Error("file path must refer to a file.");
}
if (filePath.endsWith(".script.json") || filePath.endsWith(".script.yaml")) {
throw Error(
"Cannot preview a script metadata file, point to the script content file instead (.py, .ts, .go, .sh)"
);
}
const codebases = await listSyncCodebases(opts);
const language = inferContentTypeFromFilePath(filePath, opts?.defaultTs);
const content = await Deno.readTextFile(filePath);
const input = opts.data ? await resolve(opts.data) : {};
// Check if this is a codebase script
const codebase =
language == "bun" ? findCodebase(filePath, codebases) : undefined;
let bundledContent: string | undefined = undefined;
let isTar = false;
if (codebase) {
if (codebase.customBundler) {
if (!opts.silent) {
log.info(`Using custom bundler ${codebase.customBundler} for preview`);
}
bundledContent = execSync(codebase.customBundler + " " + filePath, {
maxBuffer: 1024 * 1024 * 50,
}).toString();
} else {
const esbuild = await import("npm:esbuild@0.24.2");
if (!opts.silent) {
log.info(`Bundling ${filePath} for preview...`);
}
const startTime = performance.now();
const format = codebase.format ?? "cjs";
const out = await esbuild.build({
entryPoints: [filePath],
format: format,
bundle: true,
write: false,
external: codebase.external,
inject: codebase.inject,
define: codebase.define,
loader: codebase.loader ?? { ".node": "file" },
outdir: "/",
platform: "node",
packages: "bundle",
target: format == "cjs" ? "node20.15.1" : "esnext",
banner: codebase.banner,
});
const endTime = performance.now();
bundledContent = out.outputFiles[0].text;
// Handle multiple output files (create tarball)
if (out.outputFiles.length > 1) {
const archiveNpm = await import("npm:@ayonli/jsext/archive");
if (!opts.silent) {
log.info(`Creating tarball for multiple output files...`);
}
const tarball = new archiveNpm.Tarball();
const mainPath = filePath.split(SEP).pop()?.split(".")[0] + ".js";
const mainContent =
out.outputFiles.find((file: OutputFile) => file.path == "/" + mainPath)?.text ?? "";
tarball.append(new File([mainContent], "main.js", { type: "text/plain" }));
for (const file of out.outputFiles) {
if (file.path == "/" + mainPath) continue;
// deno-lint-ignore no-explicit-any
const fil = new File([file.contents as any], file.path.substring(1));
tarball.append(fil);
}
const blob = await streamToBlob(tarball.stream());
bundledContent = btoa(await blob.text());
isTar = true;
} else if (Array.isArray(codebase.assets) && codebase.assets.length > 0) {
// Handle assets
const archiveNpm = await import("npm:@ayonli/jsext/archive");
if (!opts.silent) {
log.info(`Adding assets to tarball...`);
}
const tarball = new archiveNpm.Tarball();
tarball.append(new File([bundledContent], "main.js", { type: "text/plain" }));
for (const asset of codebase.assets) {
const data = fs.readFileSync(asset.from);
const blob = new Blob([data], { type: "text/plain" });
const file = new File([blob], asset.to);
tarball.append(file);
}
const blob = await streamToBlob(tarball.stream());
bundledContent = btoa(await blob.text());
isTar = true;
}
if (!opts.silent) {
log.info(
`Bundled ${filePath}: ${(bundledContent.length / 1024).toFixed(0)}kB (${(
endTime - startTime
).toFixed(0)}ms)`
);
}
}
}
if (!opts.silent) {
log.info(colors.yellow(`Running preview for ${filePath}...`));
}
// For codebase scripts with bundles, we need to use a multipart form upload
if (bundledContent) {
const form = new FormData();
const previewPayload = {
content: content, // Pass the original content (frontend does this too)
path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"),
args: input,
language: language,
kind: isTar ? "tarbundle" : "bundle",
format: codebase?.format ?? "cjs",
};
form.append("preview", JSON.stringify(previewPayload));
form.append(
"file",
new Blob([bundledContent], { type: "application/javascript" })
);
const url =
workspace.remote +
"api/w/" +
workspace.workspaceId +
"/jobs/run/preview_bundle";
const response = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${workspace.token}` },
body: form,
});
if (!response.ok) {
throw new Error(
`Preview failed: ${response.status} - ${response.statusText} - ${await response.text()}`
);
}
const jobId = await response.text();
if (!opts.silent) {
await track_job(workspace.workspaceId, jobId);
}
// Wait for the job to complete and get the result
while (true) {
try {
const completedJob = await wmill.getCompletedJob({
workspace: workspace.workspaceId,
id: jobId,
});
const result = completedJob.result ?? {};
if (opts.silent) {
console.log(JSON.stringify(result, null, 2));
} else {
log.info(JSON.stringify(result, null, 2));
}
break;
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
} else {
// For regular scripts, use the standard preview API
const result = await wmill.runScriptPreviewAndWaitResult({
workspace: workspace.workspaceId,
requestBody: {
content,
path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"),
args: input,
language: language as any,
},
});
if (opts.silent) {
console.log(JSON.stringify(result, null, 2));
} else {
log.info(colors.bold.underline.green("Preview completed"));
log.info(JSON.stringify(result, null, 2));
}
}
}
const command = new Command()
.description("script related commands")
.option("--show-archived", "Enable archived scripts in output")
@@ -1114,6 +1319,20 @@ const command = new Command()
"Do not output anything other then the final output. Useful for scripting."
)
.action(run as any)
.command(
"preview",
"preview a local script without deploying it. Supports both regular and codebase scripts."
)
.arguments("<path:file>")
.option(
"-d --data <data:file>",
"Inputs specified as a JSON string or a file using @<filename> or stdin using @-."
)
.option(
"-s --silent",
"Do not output anything other than the final output. Useful for scripting."
)
.action(preview as any)
.command("bootstrap", "create a new script")
.arguments("<path:file> <language:string>")
.option("--summary <summary:string>", "script summary")