Files
windmill/cli/src/utils/metadata.ts
T
2026-06-11 07:43:45 +00:00

1477 lines
49 KiB
TypeScript

import { GlobalOptions } from "../types.ts";
import { sep as SEP } from "node:path";
import { colors } from "@cliffy/ansi/colors";
import * as log from "../core/log.ts";
import { stringify as yamlStringify } from "yaml";
import { yamlParseFile } from "./yaml.ts";
import { writeFile, stat, rm, readdir } from "node:fs/promises";
import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, writeFileSync } from "node:fs";
import * as path from "node:path";
import { createRequire } from "node:module";
import {
ScriptMetadata,
defaultScriptMetadata,
} from "../../bootstrap/script_bootstrap.ts";
import { Workspace } from "../commands/workspace/workspace.ts";
import {
ScriptLanguage,
workspaceDependenciesLanguages,
languageNeedsLock,
} from "./script_common.ts";
import { inferContentTypeFromFilePath } from "./script_common.ts";
import { getModuleFolderSuffix, isModuleEntryPoint, scriptPathToRemotePath } from "./resource_folders.ts";
import { findCodebase, yamlOptions } from "../commands/sync/sync.ts";
import { generateHash, readInlinePathSync, getHeaders, readTextFile, readTextFileSync } from "./utils.ts";
import { detectAuthGatewayChallenge } from "./http_guards.ts";
import { SyncCodebase } from "./codebase.ts";
import { argSigToJsonSchemaType } from "../../windmill-utils-internal/src/parse/parse-schema.ts";
import { getIsWin } from "./utils.ts";
import { extractRelativeImports } from "./relative_imports.ts";
import { DoubleLinkedDependencyTree } from "./dependency_tree.ts";
import { pollJobWithQueueLogging } from "./job_polling.ts";
const _require = createRequire(import.meta.url);
const _parserCache = new Map<string, Promise<any>>();
export function loadParser(pkgName: string): Promise<any> {
let p = _parserCache.get(pkgName);
if (!p) {
p = (async () => {
const mod = await import(pkgName);
const wasmPath = _require.resolve(
`${pkgName}/windmill_parser_wasm_bg.wasm`
);
await mod.default({ module_or_path: readFileSync(wasmPath) });
return mod;
})();
_parserCache.set(pkgName, p);
}
return p;
}
export class LockfileGenerationError extends Error {
constructor(message: string) {
super(message);
this.name = "LockfileGenerationError";
}
}
export class UnknownLockVersionError extends Error {
constructor(message: string) {
super(message);
this.name = "UnknownLockVersionError";
}
}
export class MalformedLockfileError extends Error {
constructor(message: string) {
super(message);
this.name = "MalformedLockfileError";
}
}
export async function getRawWorkspaceDependencies(legacyBehaviour: boolean): Promise<Record<string, string>> {
const rawWorkspaceDeps: Record<string, string> = {};
try {
const entries = await readdir("dependencies", { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) continue;
const filePath = `dependencies/${entry.name}`;
const content = await readTextFile(filePath);
// Find matching language
for (const lang of workspaceDependenciesLanguages) {
if (entry.name.endsWith(lang.filename)) {
if (legacyBehaviour) {
const contentHash = await generateHash(content + filePath);
const isUpToDate = await checkifMetadataUptodate(filePath, contentHash, undefined);
if (!isUpToDate) {
rawWorkspaceDeps[filePath] = content;
}
} else {
rawWorkspaceDeps[filePath] = content;
}
break;
}
}
}
} catch {
// dependencies directory doesn't exist
}
return rawWorkspaceDeps;
}
export function workspaceDependenciesPathToLanguageAndFilename(path: string): { name: string | undefined, language: ScriptLanguage } | undefined {
const relativePath = path.replace("dependencies/", "");
for (const { filename, language } of workspaceDependenciesLanguages) {
if (relativePath.endsWith(filename)) {
return {
name: relativePath === filename ? undefined : relativePath.replace("." + filename, ""),
language
};
}
}
}
/**
* Filters raw workspace dependencies to only include those that:
* 1. Match the given script language
* 2. Are referenced by the script's workspace dependency annotation, OR
* are the default dependency (no name) when there's no annotation
*/
export function filterWorkspaceDependencies(
rawWorkspaceDependencies: Record<string, string>,
scriptContent: string,
language: ScriptLanguage
): Record<string, string> {
const wda = extractWorkspaceDepsAnnotation(scriptContent, language);
const filtered: Record<string, string> = {};
for (const [depPath, depContent] of Object.entries(rawWorkspaceDependencies)) {
const depInfo = workspaceDependenciesPathToLanguageAndFilename(depPath);
if (depInfo && depInfo.language === language) {
if ((wda && wda.external.includes(depInfo.name ?? "default")) || (wda == null && depInfo.name == undefined)) {
filtered[depPath] = depContent;
}
}
}
return filtered;
}
export interface InlineScriptInfo {
content: string;
language: ScriptLanguage;
}
/**
* Filters workspace dependencies for multiple scripts, resolving !inline refs and computing union.
* Common helper used by flows and apps.
*/
export async function filterWorkspaceDependenciesForScripts(
scripts: InlineScriptInfo[],
rawWorkspaceDependencies: Record<string, string>,
folder: string,
sep: string
): Promise<Record<string, string>> {
const filtered: Record<string, string> = {};
for (const script of scripts) {
let content = script.content;
// Resolve !inline reference to actual content
if (content.startsWith("!inline ")) {
const filePath = folder + sep + content.replace("!inline ", "");
try {
content = await readTextFile(filePath);
} catch {
continue;
}
}
const scriptFiltered = filterWorkspaceDependencies(
rawWorkspaceDependencies,
content,
script.language
);
for (const [depPath, depContent] of Object.entries(scriptFiltered)) {
filtered[depPath] = depContent;
}
}
return filtered;
}
// on windows, when using powershell, blue is not readable
export async function blueColor(): Promise<(x: string) => void> {
const isWin = await getIsWin();
return isWin ? colors.black : colors.blue;
}
export async function generateScriptMetadataInternal(
scriptPath: string,
workspace: Workspace,
opts: GlobalOptions & {
lockOnly?: boolean | undefined;
schemaOnly?: boolean | undefined;
defaultTs?: "bun" | "deno";
rehashOnly?: boolean | undefined;
},
dryRun: boolean,
noStaleMessage: boolean,
rawWorkspaceDependencies: Record<string, string>,
codebases: SyncCodebase[],
justUpdateMetadataLock?: boolean,
tree?: DoubleLinkedDependencyTree
): Promise<string | undefined> {
// Detect folder layout: my_script__mod/script.ts
const isFolderLayout = isModuleEntryPoint(scriptPath);
// remotePath is the Windmill API path (e.g., "u/admin/my_script")
const remotePath = scriptPathToRemotePath(scriptPath);
const language = inferContentTypeFromFilePath(scriptPath, opts.defaultTs);
// For folder layout, parseMetadataFile is called with remotePath which
// will find __mod/script.yaml via the folder layout fallback
const metadataWithType = await parseMetadataFile(
remotePath,
undefined,
);
// read script content
const scriptContent = await readTextFile(scriptPath);
const metadataContent = await readTextFile(metadataWithType.path);
const filteredRawWorkspaceDependencies = filterWorkspaceDependencies(
rawWorkspaceDependencies,
scriptContent,
language
);
// Compute the module folder path early so we can include module hashes in stale check
const moduleFolderPath = isFolderLayout
? path.dirname(scriptPath)
: scriptPath.substring(0, scriptPath.indexOf(".")) + getModuleFolderSuffix();
const hasModules = existsSync(moduleFolderPath) && statSync(moduleFolderPath).isDirectory();
// In tree mode, workspace deps are tracked via the tree — exclude from hash
const depsForHash = tree ? {} : filteredRawWorkspaceDependencies;
let hash = await generateScriptHash(depsForHash, scriptContent, metadataContent);
// Compute per-module hashes for stale detection (like flow inline scripts)
let moduleHashes: Record<string, string> = {};
if (hasModules) {
moduleHashes = await computeModuleHashes(
moduleFolderPath, opts.defaultTs, tree ? {} : rawWorkspaceDependencies, isFolderLayout
);
}
const hasModuleHashes = Object.keys(moduleHashes).length > 0;
// Rehash-only fast path: trust on-disk content, write canonical hashes
// straight to the lockfile, skip staleness check and any backend trip.
// Short-circuit before computing the legacy fallback hashes since we never
// use them on this path.
if (opts.rehashOnly) {
if (hasModuleHashes) {
const sortedEntries = Object.entries(moduleHashes).sort(([a], [b]) => a.localeCompare(b));
const metaHash = await generateHash(hash + JSON.stringify(sortedEntries));
await clearGlobalLock(remotePath);
await updateMetadataGlobalLock(remotePath, metaHash, SCRIPT_TOP_HASH);
for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) {
await updateMetadataGlobalLock(remotePath, moduleHash, modulePath);
}
} else {
// Mirror the hasModuleHashes branch: clear first so any legacy
// "./"-prefixed duplicate gets collapsed alongside the canonical write.
await clearGlobalLock(remotePath);
await updateMetadataGlobalLock(remotePath, hash);
}
return;
}
const conf = await readLockfile();
// If modules exist, combine main script hash + module hashes into a meta-hash
let checkHash = hash;
let checkSubpath: string | undefined;
if (hasModuleHashes) {
const sortedEntries = Object.entries(moduleHashes).sort(([a], [b]) => a.localeCompare(b));
checkHash = await generateHash(hash + JSON.stringify(sortedEntries));
checkSubpath = SCRIPT_TOP_HASH;
}
// Use checkHash (includes module hashes) so module changes are detected as stale
const isDirectlyStale = !(await checkifMetadataUptodate(remotePath, checkHash, conf, checkSubpath));
// Tree-based dependency tracking
if (tree) {
if (dryRun) {
// First pass: populate tree with script and its imports
const imports = await extractRelativeImports(scriptContent, remotePath, language);
await tree.addNode(remotePath, scriptContent, language, metadataContent, imports, "script", remotePath, scriptPath, isDirectlyStale);
return;
}
// Second pass: proceed to generate (caller verified this script is stale via tree)
} else {
// Legacy path: use existing staleness check
if (await checkifMetadataUptodate(remotePath, checkHash, conf, checkSubpath)) {
if (!noStaleMessage) {
log.info(
colors.green(`Script ${remotePath} metadata is up-to-date, skipping`)
);
}
return;
} else if (dryRun) {
let detail = `${remotePath} (${language})`;
if (hasModuleHashes) {
const changed: string[] = [];
for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) {
if (!(await checkifMetadataUptodate(remotePath, moduleHash, conf, modulePath))) {
changed.push(modulePath);
}
}
if (changed.length > 0) {
detail += ` [changed modules: ${changed.join(", ")}]`;
}
}
return detail;
}
}
if (!justUpdateMetadataLock && !noStaleMessage) {
log.info(colors.gray(`Generating metadata for ${scriptPath}`));
}
const metadataParsedContent = metadataWithType?.payload as Record<
string,
any
>;
if (!opts.lockOnly && !justUpdateMetadataLock) {
await updateScriptSchema(
scriptContent,
language,
metadataParsedContent,
scriptPath
);
}
if (!opts.schemaOnly && !justUpdateMetadataLock) {
const hasCodebase = findCodebase(scriptPath, codebases) != undefined;
if (!hasCodebase) {
const tempScriptRefs = tree?.getTempScriptRefs(remotePath);
const lockPathOverride = isFolderLayout
? path.dirname(scriptPath) + "/script.lock"
: undefined;
await updateScriptLock(
workspace,
scriptContent,
language,
remotePath,
metadataParsedContent,
filteredRawWorkspaceDependencies,
tempScriptRefs,
lockPathOverride,
);
} else {
metadataParsedContent.lock = "";
}
// Generate locks for modules in __mod/ folder
if (hasModules) {
// Identify which modules changed by comparing per-module hashes
let changedModules: string[] | undefined;
if (hasModuleHashes) {
changedModules = [];
for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) {
if (!(await checkifMetadataUptodate(remotePath, moduleHash, conf, modulePath))) {
changedModules.push(modulePath);
}
}
if (changedModules.length === 0) {
changedModules = undefined; // no modules changed, skip lock regeneration
}
}
await updateModuleLocks(
workspace, moduleFolderPath, "", remotePath,
rawWorkspaceDependencies, opts.defaultTs, changedModules,
);
}
} else {
if (isFolderLayout) {
metadataParsedContent.lock =
"!inline " + remotePath.replaceAll(SEP, "/") + getModuleFolderSuffix() + "/script.lock";
} else {
metadataParsedContent.lock =
"!inline " + remotePath.replaceAll(SEP, "/") + ".script.lock";
}
}
// Write metadata back to the correct path
let metaPath: string;
let newMetadataContent: string;
if (isFolderLayout) {
if (metadataWithType.isJson) {
metaPath = path.dirname(scriptPath) + "/script.json";
newMetadataContent = JSON.stringify(metadataParsedContent);
} else {
metaPath = path.dirname(scriptPath) + "/script.yaml";
newMetadataContent = yamlStringify(metadataParsedContent, yamlOptions);
}
} else {
if (metadataWithType.isJson) {
metaPath = remotePath + ".script.json";
newMetadataContent = JSON.stringify(metadataParsedContent);
} else {
metaPath = remotePath + ".script.yaml";
newMetadataContent = yamlStringify(metadataParsedContent, yamlOptions);
}
}
// When justUpdateMetadataLock (sync pull), the metadata file is NOT rewritten,
// so use the raw file content for hashing to avoid YAML round-trip differences
// (e.g. hand-edited YAML that serializes differently after parse + stringify).
const metadataContentUsedForHash = justUpdateMetadataLock ? metadataContent : newMetadataContent;
hash = await generateScriptHash(
depsForHash,
scriptContent,
metadataContentUsedForHash
);
// Store hashes in wmill-lock.yaml
if (hasModuleHashes) {
// Use per-module hash tracking (like flow inline scripts)
const sortedEntries = Object.entries(moduleHashes).sort(([a], [b]) => a.localeCompare(b));
const metaHash = await generateHash(hash + JSON.stringify(sortedEntries));
await clearGlobalLock(remotePath);
await updateMetadataGlobalLock(remotePath, metaHash, SCRIPT_TOP_HASH);
for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) {
await updateMetadataGlobalLock(remotePath, moduleHash, modulePath);
}
} else {
await updateMetadataGlobalLock(remotePath, hash);
}
if (!justUpdateMetadataLock) {
await writeFile(metaPath, newMetadataContent, "utf-8");
}
return `${remotePath} (${language})`;
}
export async function updateScriptSchema(
scriptContent: string,
language: ScriptLanguage,
metadataContent: Record<string, any>,
path: string
): Promise<void> {
// infer schema from script content and update it inplace
const result = await inferSchema(
language,
scriptContent,
metadataContent.schema,
path
);
metadataContent.schema = result.schema;
if (result.has_preprocessor) {
metadataContent.has_preprocessor = result.has_preprocessor;
} else {
delete metadataContent.has_preprocessor;
}
// auto_kind is intentionally not written to metadata — it is auto-detected
// by the parser at deploy time from script content. no_main_func is the
// legacy predecessor of auto_kind and is stripped for the same reason.
delete metadataContent.auto_kind;
delete metadataContent.no_main_func;
}
// ---------------------------------------------------------------------------
// Annotation parser — mirrors backend's WorkspaceDependenciesAnnotatedRefs::parse
// (windmill-common/src/workspace_dependencies.rs) so the cache key captures
// exactly the parts of scriptContent that affect lockfile generation.
// ---------------------------------------------------------------------------
type AnnotationMode = "manual" | "extra";
interface WorkspaceDepsAnnotation {
mode: AnnotationMode;
external: string[];
inline: string | null;
}
const LANG_ANNOTATION_CONFIG: Partial<
Record<ScriptLanguage, { comment: string; keyword: string; validityRe?: RegExp }>
> = {
python3: { comment: "#", keyword: "requirements", validityRe: /^#\s?(\S+)\s*$/ },
bun: { comment: "//", keyword: "package_json" },
nativets: { comment: "//", keyword: "package_json" },
go: { comment: "//", keyword: "go_mod" },
php: { comment: "//", keyword: "composer_json" },
powershell: { comment: "#", keyword: "modules_json" },
};
export function extractWorkspaceDepsAnnotation(
scriptContent: string,
language: ScriptLanguage,
): WorkspaceDepsAnnotation | null {
const config = LANG_ANNOTATION_CONFIG[language];
if (!config) return null;
const { comment, keyword, validityRe } = config;
const extraMarkerUnderscore = `extra_${keyword}:`;
const extraMarkerHyphen = `extra-${keyword}:`;
const manualMarker = `${keyword}:`;
const stripComment = (l: string): string | null => {
if (!l.startsWith(comment)) return null;
return l.substring(comment.length).trimStart();
};
const isExtra = (l: string): boolean => {
const s = stripComment(l);
return s !== null && (s.startsWith(extraMarkerUnderscore) || s.startsWith(extraMarkerHyphen));
};
const isManual = (l: string): boolean => {
const s = stripComment(l);
return s !== null && s.startsWith(manualMarker);
};
const lines = scriptContent.split("\n");
// Find first annotation line (mirrors Rust find_position)
let pos = -1;
for (let i = 0; i < lines.length; i++) {
if (isExtra(lines[i]) || isManual(lines[i])) {
pos = i;
break;
}
}
if (pos === -1) return null;
const annotationLine = lines[pos];
const mode: AnnotationMode = isExtra(annotationLine) ? "extra" : "manual";
// Parse external references from the annotation line
const marker = mode === "extra"
? (annotationLine.includes(extraMarkerUnderscore) ? extraMarkerUnderscore : extraMarkerHyphen)
: manualMarker;
const unparsed = annotationLine.replaceAll(marker, "").replaceAll(comment, "");
const external = unparsed
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
// Parse inline deps from subsequent lines
const inlineParts: string[] = [];
for (let i = pos + 1; i < lines.length; i++) {
const l = lines[i];
if (validityRe) {
const match = validityRe.exec(l);
if (match && match[1]) {
inlineParts.push(match[1]);
} else {
break;
}
} else {
if (!l.startsWith(comment)) {
break;
}
inlineParts.push(l.substring(comment.length));
}
}
const inlineStr = inlineParts.join("\n");
const inline = inlineStr.trim().length > 0 ? inlineStr : null;
return { mode, external, inline };
}
// Mirrors backend ScriptLang::as_comment_lit (windmill-types/src/scripts.rs)
// for the languages that can reach the lock cache.
const LANG_COMMENT_LIT: Partial<Record<ScriptLanguage, string>> = {
python3: "#",
ansible: "#",
powershell: "#",
bun: "//",
nativets: "//",
deno: "//",
go: "//",
php: "//",
rust: "//!",
};
/**
* Returns the leading comment/blank-line block of the script, verbatim.
*
* Backend annotation parsing (PythonAnnotations / TypeScriptAnnotations via
* the `annotations!` macro, and `# py:` version lines) only reads this leading
* block, and those annotations (e.g. `# py311`, `# py: <spec>`, `//npm`,
* `# no_cache`) change the generated lockfile even when workspace
* dependencies fully specify the requirements. With workspace deps the
* backend runs in manual mode and ignores the script body, so two scripts
* sharing the same header, annotation, and deps resolve to the same lockfile.
*/
export function extractLockRelevantHeader(
scriptContent: string,
language: ScriptLanguage
): string {
const comment = LANG_COMMENT_LIT[language];
// Unknown language: be conservative and treat the whole content as relevant.
if (!comment) return scriptContent;
const headerLines: string[] = [];
for (const line of scriptContent.split("\n")) {
if (line.trim() === "" || line.startsWith(comment)) {
headerLines.push(line);
} else {
break;
}
}
return headerLines.join("\n");
}
export async function computeLockCacheKey(
scriptContent: string,
language: ScriptLanguage,
rawWorkspaceDependencies: Record<string, string>,
tempScriptRefs?: Record<string, string>
): Promise<string> {
const annotation = extractWorkspaceDepsAnnotation(scriptContent, language);
const annotationStr = annotation
? `${annotation.mode}|${annotation.external.join(",")}|${annotation.inline ?? ""}`
: "none";
const sortedDepsKeys = Object.keys(rawWorkspaceDependencies).sort();
const depsStr = sortedDepsKeys.map((k) => `${k}=${rawWorkspaceDependencies[k]}`).join(";");
const tempRefsStr = tempScriptRefs
? Object.keys(tempScriptRefs).sort().map((k) => `${k}=${tempScriptRefs[k]}`).join(";")
: "";
// The lock only ignores the script body when the backend resolves it in
// manual workspace-deps mode: either the script has a manual annotation
// (external refs are fetched server-side by name when not in the raw map),
// or it has no annotation but its language matches a default workspace deps
// file (callers like updateModuleLocks pass the unfiltered map, so
// re-filter here). In every other case — extra mode (appends the script's
// scanned imports), deno/rust/ansible modules that only see deps of other
// languages, relative-import resolution via tempScriptRefs — the backend
// scans the script's own content, so it must be in the key.
const isManualWorkspaceDeps = annotation
? annotation.mode === "manual"
: Object.keys(
filterWorkspaceDependencies(rawWorkspaceDependencies, scriptContent, language)
).length > 0;
const contentStr = isManualWorkspaceDeps
? extractLockRelevantHeader(scriptContent, language)
: scriptContent;
return await generateHash(
`${language}|${annotationStr}|${depsStr}|${tempRefsStr}|${contentStr}`
);
}
const lockCache = new Map<string, string>();
export function clearLockCache(): void {
lockCache.clear();
}
async function fetchScriptLock(
workspace: Workspace,
scriptContent: string,
language: ScriptLanguage,
remotePath: string,
rawWorkspaceDependencies: Record<string, string>,
tempScriptRefs?: Record<string, string>
): Promise<string> {
const hasRawDeps = Object.keys(rawWorkspaceDependencies).length > 0;
const hasTempRefs = tempScriptRefs && Object.keys(tempScriptRefs).length > 0;
const cacheKey = (hasRawDeps || hasTempRefs)
? await computeLockCacheKey(scriptContent, language, rawWorkspaceDependencies, tempScriptRefs)
: undefined;
if (cacheKey && lockCache.has(cacheKey)) {
log.debug(`Using cached lockfile for ${remotePath}`);
return lockCache.get(cacheKey)!;
}
const extraHeaders = getHeaders();
const queueResponse = await fetch(
`${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/dependencies_async`,
{
method: "POST",
headers: {
Cookie: `token=${workspace.token}`,
"Content-Type": "application/json",
...extraHeaders,
},
body: JSON.stringify({
raw_scripts: [
{
raw_code: scriptContent,
language: language,
script_path: remotePath,
},
],
raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0
? rawWorkspaceDependencies : null,
entrypoint: remotePath,
temp_script_refs: tempScriptRefs && Object.keys(tempScriptRefs).length > 0
? tempScriptRefs : null,
}),
}
);
await detectAuthGatewayChallenge(
queueResponse,
`${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/dependencies_async`,
);
if (!queueResponse.ok) {
let bodyText = "";
try {
bodyText = await queueResponse.text();
} catch { /* ignore */ }
throw new LockfileGenerationError(
`Failed to queue dependencies job: ${queueResponse.status} ${queueResponse.statusText}, ${bodyText}`
);
}
const jobId = (await queueResponse.text()).trim();
let completion;
try {
completion = await pollJobWithQueueLogging(
workspace.workspaceId,
jobId,
{ label: `deps ${remotePath}` },
);
} catch (e: any) {
throw new LockfileGenerationError(
`Failed to poll dependencies job ${jobId}: ${e?.message ?? e}`
);
}
const result = completion.result as any;
if (!completion.success) {
const message =
result?.error?.message ??
(typeof result === "string" ? result : JSON.stringify(result, null, 2));
throw new LockfileGenerationError(`Failed to generate lockfile: ${message}`);
}
const lock = result?.lock;
if (lock === undefined) {
throw new LockfileGenerationError(
`Failed to generate lockfile: ${JSON.stringify(result, null, 2)}`
);
}
if (cacheKey) {
lockCache.set(cacheKey, lock);
}
return lock;
}
async function updateScriptLock(
workspace: Workspace,
scriptContent: string,
language: ScriptLanguage,
remotePath: string,
metadataContent: Record<string, any>,
rawWorkspaceDependencies: Record<string, string>,
tempScriptRefs?: Record<string, string>,
lockPathOverride?: string,
): Promise<void> {
if (
!(
(workspaceDependenciesLanguages.some((l) => l.language == language) &&
language !== "powershell") ||
language == "deno" ||
language == "rust" ||
language == "ansible"
)
) {
return;
}
if (Object.keys(rawWorkspaceDependencies).length > 0) {
const dependencyPaths = Object.keys(rawWorkspaceDependencies).join(', ');
log.debug(`Generating script lock for ${remotePath} with raw workspace dependencies: ${dependencyPaths}`);
}
const lock = await fetchScriptLock(
workspace,
scriptContent,
language,
remotePath,
rawWorkspaceDependencies,
tempScriptRefs
);
const lockPath = lockPathOverride ?? remotePath + ".script.lock";
if (lock != "") {
await writeFile(lockPath, lock, "utf-8");
metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/");
} else {
try {
if (await stat(lockPath)) {
await rm(lockPath);
}
} catch (e) {
log.info(colors.yellow(`Error removing lock file ${lockPath}: ${e}`));
}
metadataContent.lock = "";
}
}
/**
* Generate locks for all module files in a __mod/ directory.
* Recursively walks the directory and generates a lock for each module
* whose language requires one.
*/
async function updateModuleLocks(
workspace: Workspace,
dirPath: string,
relPrefix: string,
scriptRemotePath: string,
rawWorkspaceDependencies: Record<string, string>,
defaultTs: "bun" | "deno" | undefined,
changedModules?: string[],
): Promise<void> {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name;
if (entry.isDirectory()) {
await updateModuleLocks(workspace, fullPath, relPath, scriptRemotePath, rawWorkspaceDependencies, defaultTs, changedModules);
} else if (entry.isFile()
&& !entry.name.endsWith(".lock")
// In folder layout, skip entry point files (script.{ext}, script.yaml, script.json, script.lock)
&& !(relPrefix === "" && entry.name.startsWith("script."))
) {
let modLanguage: ScriptLanguage;
try {
modLanguage = inferContentTypeFromFilePath(entry.name, defaultTs);
} catch {
continue; // skip files with unrecognized extensions
}
if (!languageNeedsLock(modLanguage)) continue;
// Skip unchanged modules when per-module hash tracking is active
if (changedModules) {
const normalizedRelPath = normalizeLockPath(relPath);
if (!changedModules.includes(normalizedRelPath)) continue;
}
const moduleContent = readTextFileSync(fullPath);
const moduleRemotePath = scriptRemotePath + "/" + relPath;
log.debug(`Generating lock for module ${relPath}`);
try {
const lock = await fetchScriptLock(
workspace,
moduleContent,
modLanguage,
moduleRemotePath,
rawWorkspaceDependencies,
);
const baseName = entry.name.replace(/\.[^.]+$/, '');
const lockPath = path.join(dirPath, baseName + ".lock");
if (lock != "") {
writeFileSync(lockPath, lock, "utf-8");
} else {
try {
if (existsSync(lockPath)) {
const { rm: rmAsync } = await import("node:fs/promises");
await rmAsync(lockPath);
}
} catch {
// ignore
}
}
} catch (e) {
log.info(colors.yellow(`Failed to generate lock for module ${relPath}: ${e}`));
}
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////
// below functions copied from Windmill's FE inferArgs function. TODO: refactor //
////////////////////////////////////////////////////////////////////////////////////////////
export async function inferSchema(
language: ScriptLanguage,
content: string,
currentSchema: any,
path: string
): Promise<{
schema: any;
has_preprocessor: boolean | undefined;
auto_kind: string | undefined;
}> {
let inferedSchema: any;
if (language === "python3") {
const { parse_python } = await loadParser("windmill-parser-wasm-py");
inferedSchema = JSON.parse(parse_python(content));
} else if (language === "nativets") {
const { parse_deno } = await loadParser("windmill-parser-wasm-ts");
inferedSchema = JSON.parse(parse_deno(content));
} else if (language === "bun") {
const { parse_deno } = await loadParser("windmill-parser-wasm-ts");
inferedSchema = JSON.parse(parse_deno(content));
} else if (language === "deno") {
const { parse_deno } = await loadParser("windmill-parser-wasm-ts");
inferedSchema = JSON.parse(parse_deno(content));
} else if (language === "go") {
const { parse_go } = await loadParser("windmill-parser-wasm-go");
inferedSchema = JSON.parse(parse_go(content));
} else if (language === "mysql") {
const { parse_mysql } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_mysql(content));
inferedSchema.args = [
{ name: "database", typ: { resource: "mysql" } },
...inferedSchema.args,
];
} else if (language === "bigquery") {
const { parse_bigquery } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_bigquery(content));
inferedSchema.args = [
{ name: "database", typ: { resource: "bigquery" } },
...inferedSchema.args,
];
} else if (language === "oracledb") {
const { parse_oracledb } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_oracledb(content));
inferedSchema.args = [
{ name: "database", typ: { resource: "oracledb" } },
...inferedSchema.args,
];
} else if (language === "snowflake") {
const { parse_snowflake } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_snowflake(content));
inferedSchema.args = [
{ name: "database", typ: { resource: "snowflake" } },
...inferedSchema.args,
];
} else if (language === "mssql") {
const { parse_mssql } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_mssql(content));
inferedSchema.args = [
{ name: "database", typ: { resource: "ms_sql_server" } },
...inferedSchema.args,
];
} else if (language === "postgresql") {
const { parse_sql } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_sql(content));
inferedSchema.args = [
{ name: "database", typ: { resource: "postgresql" } },
...inferedSchema.args,
];
} else if (language === "duckdb") {
const { parse_duckdb } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_duckdb(content));
} else if (language === "graphql") {
const { parse_graphql } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_graphql(content));
inferedSchema.args = [
{ name: "api", typ: { resource: "graphql" } },
...inferedSchema.args,
];
} else if (language === "bash") {
const { parse_bash } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_bash(content));
} else if (language === "powershell") {
const { parse_powershell } = await loadParser("windmill-parser-wasm-regex");
inferedSchema = JSON.parse(parse_powershell(content));
} else if (language === "php") {
const { parse_php } = await loadParser("windmill-parser-wasm-php");
inferedSchema = JSON.parse(parse_php(content));
} else if (language === "rust") {
const { parse_rust } = await loadParser("windmill-parser-wasm-rust");
inferedSchema = JSON.parse(parse_rust(content));
} else if (language === "csharp") {
const { parse_csharp } = await loadParser("windmill-parser-wasm-csharp");
inferedSchema = JSON.parse(parse_csharp(content));
} else if (language === "nu") {
const { parse_nu } = await loadParser("windmill-parser-wasm-nu");
inferedSchema = JSON.parse(parse_nu(content));
} else if (language === "ansible") {
const { parse_ansible } = await loadParser("windmill-parser-wasm-yaml");
inferedSchema = JSON.parse(parse_ansible(content));
} else if (language === "java") {
const { parse_java } = await loadParser("windmill-parser-wasm-java");
inferedSchema = JSON.parse(parse_java(content));
} else if (language === "ruby") {
const { parse_ruby } = await loadParser("windmill-parser-wasm-ruby");
inferedSchema = JSON.parse(parse_ruby(content));
} else if (language === "rlang") {
const { parse_r } = await loadParser("windmill-parser-wasm-r");
inferedSchema = JSON.parse(parse_r(content));
// for related places search: ADD_NEW_LANG
} else {
throw new Error("Invalid language: " + language);
}
if (inferedSchema.type == "Invalid") {
log.info(
colors.yellow(
`Script ${path} invalid, it cannot be parsed to infer schema.`
)
);
return {
schema: defaultScriptMetadata().schema,
has_preprocessor: false,
auto_kind: undefined,
};
}
if (!currentSchema) {
currentSchema = {};
}
currentSchema.required = [];
const oldProperties = JSON.parse(
JSON.stringify(currentSchema?.properties ?? {})
);
currentSchema.properties = {};
for (const arg of inferedSchema.args) {
if (!(arg.name in oldProperties)) {
currentSchema.properties[arg.name] = { description: "", type: "" };
} else {
currentSchema.properties[arg.name] = oldProperties[arg.name];
}
currentSchema.properties[arg.name] = sortObject(
currentSchema.properties[arg.name]
);
argSigToJsonSchemaType(arg.typ, currentSchema.properties[arg.name]);
// For T | T[] detection for debouncing arg accumulation
if ((arg as any).otyp && (arg as any).otyp.includes('[') && (arg as any).otyp.includes('|')) {
currentSchema.properties[arg.name].originalType = (arg as any).otyp
}
currentSchema.properties[arg.name].default = arg.default;
if (!arg.has_default && !currentSchema.required.includes(arg.name)) {
currentSchema.required.push(arg.name);
}
}
return {
schema: currentSchema,
has_preprocessor: inferedSchema.has_preprocessor,
auto_kind: inferedSchema.auto_kind,
};
}
function sortObject(obj: any): any {
return Object.keys(obj)
.sort()
.reduce(
(acc, key) => ({
...acc,
[key]: obj[key],
}),
{}
);
}
////////////////////////////////////////////////////////////////////////////////////////////
// end of refactoring TODO //
////////////////////////////////////////////////////////////////////////////////////////////
export function replaceLock(o?: { lock?: string | string[] }) {
if (Array.isArray(o?.lock)) {
o.lock = o.lock.join("\n");
}
if (o?.lock?.startsWith("!inline ")) {
try {
const lockPath = o?.lock?.split(" ")[1];
o.lock = readInlinePathSync(lockPath);
} catch (e) {
log.info(
colors.yellow(`Failed to read lockfile, doing as if it was empty: ${e}`)
);
o.lock = "";
}
}
}
export async function parseMetadataFileIfExists(
scriptPath: string
): Promise<{ isJson: boolean; payload: any; path: string } | undefined> {
let metadataFilePath = scriptPath + ".script.json";
try {
await stat(metadataFilePath);
const payload = JSON.parse(await readTextFile(metadataFilePath));
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: true,
};
} catch {
try {
metadataFilePath = scriptPath + ".script.yaml";
await stat(metadataFilePath);
const payload: any = await yamlParseFile(metadataFilePath);
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: false,
};
} catch {
return undefined;
}
}
}
export async function parseMetadataFile(
scriptPath: string,
generateMetadataIfMissing:
| (GlobalOptions & {
path: string;
workspaceRemote: Workspace;
schemaOnly?: boolean;
rawWorkspaceDependencies: Record<string, string>;
codebases: SyncCodebase[]
})
| undefined
): Promise<{ isJson: boolean; payload: any; path: string }> {
let metadataFilePath = scriptPath + ".script.json";
try {
await stat(metadataFilePath);
return {
path: metadataFilePath,
payload: JSON.parse(await readTextFile(metadataFilePath)),
isJson: true,
};
} catch {
try {
metadataFilePath = scriptPath + ".script.yaml";
await stat(metadataFilePath);
const payload: any = await yamlParseFile(metadataFilePath);
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: false,
};
} catch {
// Try folder layout: {scriptPath}__mod/script.yaml or .json
const moduleFolderMeta = scriptPath + getModuleFolderSuffix();
try {
metadataFilePath = moduleFolderMeta + "/script.json";
await stat(metadataFilePath);
return {
path: metadataFilePath,
payload: JSON.parse(await readTextFile(metadataFilePath)),
isJson: true,
};
} catch {
try {
metadataFilePath = moduleFolderMeta + "/script.yaml";
await stat(metadataFilePath);
const payload: any = await yamlParseFile(metadataFilePath);
replaceLock(payload);
return {
path: metadataFilePath,
payload,
isJson: false,
};
} catch {
// fall through to create metadata
}
}
}
}
// no metadata file at all. Create it
metadataFilePath = scriptPath + ".script.yaml";
log.info(
(await blueColor())(
`Creating script metadata file for ${metadataFilePath}`
)
);
let scriptInitialMetadata = defaultScriptMetadata();
const lockPath = scriptPath + ".script.lock";
scriptInitialMetadata.lock = "!inline " + lockPath;
const scriptInitialMetadataYaml = yamlStringify(
scriptInitialMetadata as Record<string, any>,
yamlOptions
);
await writeFile(metadataFilePath, scriptInitialMetadataYaml, { flag: "wx", encoding: "utf-8" });
await writeFile(lockPath, "", { flag: "wx", encoding: "utf-8" });
if (generateMetadataIfMissing) {
log.info(
(await blueColor())(
`Generating lockfile and schema for ${metadataFilePath}`
)
);
try {
await generateScriptMetadataInternal(
generateMetadataIfMissing.path,
generateMetadataIfMissing.workspaceRemote,
generateMetadataIfMissing,
false,
false,
generateMetadataIfMissing.rawWorkspaceDependencies,
generateMetadataIfMissing.codebases,
false
);
scriptInitialMetadata = (await yamlParseFile(
metadataFilePath
)) as ScriptMetadata;
if (!generateMetadataIfMissing.schemaOnly) {
replaceLock(scriptInitialMetadata);
}
} catch (e) {
log.info(
colors.yellow(
`Failed to generate lockfile and schema for ${metadataFilePath}: ${e}`
)
);
}
}
return {
path: metadataFilePath,
payload: scriptInitialMetadata,
isJson: false,
};
}
export type LockVersion = "v2";
export interface Lock {
version?: LockVersion;
locks?: { [path: string]: string | { [subpath: string]: string } };
}
const WMILL_LOCKFILE = "wmill-lock.yaml";
const CURRENT_LOCK_VERSION: LockVersion = "v2";
// Versions this CLI knows how to read/write. An unknown value indicates the
// lockfile was written by a newer CLI; we refuse to touch it rather than
// silently fall through to a legacy code path (the bug that 1.692.0 had with
// the proposed v3 marker).
// Real v1 lockfiles never had a version field (it was added with v2). The
// "v1" string is included here only to be lenient about manual edits that
// label a v1 lockfile explicitly — the downstream isFlatKeyed check ensures
// it still goes through the legacy nested-key path.
const KNOWN_LOCK_VERSIONS: readonly string[] = ["v1", "v2"];
const SCRIPT_TOP_HASH = "__script_hash";
/**
* Normalizes a path to use Linux separators (forward slashes) and strips a
* leading "./" prefix. Forward slashes ensure wmill-lock.yaml is portable
* across Windows and Linux; stripping "./" collapses entries from older CLIs
* that joined paths against the current directory before storing.
*/
export function normalizeLockPath(p: string): string {
let n = p.replace(/\\/g, "/");
if (n.startsWith("./")) n = n.slice(2);
return n;
}
// When set, `clearGlobalLock` and `updateMetadataGlobalLock` mutate this
// in-memory copy instead of doing a full read-modify-write on disk for every
// call. Callers that fan out item processing through a worker pool wrap the
// pool with `beginLockfileBatch()`/`flushLockfileBatch()` so the lockfile is
// only written once at the end — see `generate-metadata` parallelism.
let inMemoryLock: Lock | null = null;
export async function beginLockfileBatch(): Promise<void> {
if (inMemoryLock) return;
inMemoryLock = await readLockfile();
}
export async function flushLockfileBatch(): Promise<void> {
if (!inMemoryLock) return;
// Write first, then clear: if the disk write throws (e.g. ENOSPC), the
// buffered updates remain in memory and a retry can re-attempt the flush.
await writeFile(
WMILL_LOCKFILE,
yamlStringify(inMemoryLock as Record<string, any>, yamlOptions),
"utf-8",
);
inMemoryLock = null;
}
export async function readLockfile(): Promise<Lock> {
if (inMemoryLock) return inMemoryLock;
let parsed: unknown;
try {
parsed = await yamlParseFile(WMILL_LOCKFILE);
} catch {
const lock: Lock = { locks: {}, version: CURRENT_LOCK_VERSION };
await writeFile(WMILL_LOCKFILE, yamlStringify(lock, yamlOptions), "utf-8");
log.info(colors.green("wmill-lock.yaml created"));
return lock;
}
if (typeof parsed != "object" || parsed == null) {
throw new MalformedLockfileError(
"wmill-lock.yaml is malformed (expected an object). " +
"Refusing to operate to avoid corrupting the lockfile.",
);
}
const conf = parsed as Lock;
if (conf.version != null && !KNOWN_LOCK_VERSIONS.includes(conf.version)) {
throw new UnknownLockVersionError(
`wmill-lock.yaml is at unknown version "${conf.version}". This was ` +
`written by a newer wmill CLI; please upgrade with \`wmill upgrade\`. ` +
`Refusing to operate to avoid corrupting the lockfile.`,
);
}
return conf;
}
function v2LockPath(path: string, subpath?: string) {
const normalizedPath = normalizeLockPath(path);
if (subpath) {
return `${normalizedPath}+${normalizeLockPath(subpath)}`;
} else {
return normalizedPath;
}
}
export async function checkifMetadataUptodate(
path: string,
hash: string,
conf: Lock | undefined,
subpath?: string
) {
if (!conf) {
conf = await readLockfile();
}
if (!conf.locks) {
return false;
}
const isFlatKeyed = conf?.version === "v2";
// Older CLIs sometimes wrote entries with a leading "./" prefix; some
// lockfiles even contain both forms with different stale values. Accept
// either form so duplicate-key drift doesn't cause false positives.
if (isFlatKeyed) {
const key = v2LockPath(path, subpath);
return conf.locks?.[key] === hash || conf.locks?.["./" + key] === hash;
}
for (const p of [path, "./" + path]) {
const obj = conf.locks?.[p];
const v = subpath && typeof obj == "object" ? obj?.[subpath] : obj;
if (v === hash) return true;
}
return false;
}
export async function generateScriptHash(
rawWorkspaceDependencies: Record<string, string>,
scriptContent: string,
newMetadataContent: string
) {
return await generateHash(
JSON.stringify(rawWorkspaceDependencies) + scriptContent + newMetadataContent
);
}
async function computeModuleHashes(
moduleFolderPath: string,
defaultTs: "bun" | "deno" | undefined,
rawWorkspaceDependencies: Record<string, string>,
isFolderLayout: boolean,
): Promise<Record<string, string>> {
const hashes: Record<string, string> = {};
async function readDir(dirPath: string, relPrefix: string) {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name;
const isTopLevel = relPrefix === "";
if (entry.isDirectory()) {
await readDir(fullPath, relPath);
} else if (
entry.isFile() &&
!entry.name.endsWith(".lock") &&
!(isFolderLayout && isTopLevel && entry.name.startsWith("script."))
) {
try {
inferContentTypeFromFilePath(entry.name, defaultTs);
} catch {
continue;
}
const content = readTextFileSync(fullPath);
const normalizedPath = normalizeLockPath(relPath);
hashes[normalizedPath] = await generateHash(
content + JSON.stringify(rawWorkspaceDependencies)
);
}
}
}
await readDir(moduleFolderPath, "");
return hashes;
}
export async function clearGlobalLock(path: string): Promise<void> {
const conf = await readLockfile();
if (!conf?.locks) {
conf.locks = {};
}
const isFlatKeyed = conf?.version === "v2";
if (isFlatKeyed) {
// Remove the specific flat-keyed lock entry. Match both the canonical
// form and the legacy "./"-prefixed form so duplicate entries left over
// from older CLIs get cleaned up alongside the canonical write.
// Match exactly `key` or `key+<subpath>` (and the same for the legacy
// "./"-prefixed form). The "+" separator boundary is critical: a plain
// startsWith("f/foo") would also match "f/foobar+..." entries belonging
// to a sibling script.
const key = v2LockPath(path);
const legacyKey = "./" + key;
if (conf.locks) {
Object.keys(conf.locks).forEach((k) => {
if (!conf.locks) return;
if (
k === key || k.startsWith(key + "+") ||
k === legacyKey || k.startsWith(legacyKey + "+")
) {
delete conf.locks[k];
}
});
}
if (!inMemoryLock) {
await writeFile(
WMILL_LOCKFILE,
yamlStringify(conf as Record<string, any>, yamlOptions),
"utf-8"
);
}
}
}
export async function updateMetadataGlobalLock(
path: string,
hash: string,
subpath?: string
): Promise<void> {
const conf = await readLockfile();
if (!conf?.locks) {
conf.locks = {};
}
const isFlatKeyed = conf?.version === "v2";
if (isFlatKeyed) {
conf.locks[v2LockPath(path, subpath)] = hash;
} else {
if (subpath) {
let prev: any = conf.locks[path];
if (!prev || typeof prev != "object") {
prev = {};
conf.locks[path] = prev;
}
prev[subpath] = hash;
} else {
conf.locks[path] = hash;
}
}
if (!inMemoryLock) {
await writeFile(
WMILL_LOCKFILE,
yamlStringify(conf as Record<string, any>, yamlOptions),
"utf-8"
);
}
}