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>(); export function loadParser(pkgName: string): Promise { 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> { const rawWorkspaceDeps: Record = {}; 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, scriptContent: string, language: ScriptLanguage ): Record { const wda = extractWorkspaceDepsAnnotation(scriptContent, language); const filtered: Record = {}; 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, folder: string, sep: string ): Promise> { const filtered: Record = {}; 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, codebases: SyncCodebase[], justUpdateMetadataLock?: boolean, tree?: DoubleLinkedDependencyTree ): Promise { // 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 = {}; 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, path: string ): Promise { // 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 > = { 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 }; } export async function computeLockCacheKey( scriptContent: string, language: ScriptLanguage, rawWorkspaceDependencies: Record, tempScriptRefs?: Record ): Promise { 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(";") : ""; return await generateHash(`${language}|${annotationStr}|${depsStr}|${tempRefsStr}`); } const lockCache = new Map(); export function clearLockCache(): void { lockCache.clear(); } async function fetchScriptLock( workspace: Workspace, scriptContent: string, language: ScriptLanguage, remotePath: string, rawWorkspaceDependencies: Record, tempScriptRefs?: Record ): Promise { 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, rawWorkspaceDependencies: Record, tempScriptRefs?: Record, lockPathOverride?: string, ): Promise { 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, defaultTs: "bun" | "deno" | undefined, changedModules?: string[], ): Promise { 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; 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, 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 { if (inMemoryLock) return; inMemoryLock = await readLockfile(); } export async function flushLockfileBatch(): Promise { 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, yamlOptions), "utf-8", ); inMemoryLock = null; } export async function readLockfile(): Promise { 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, scriptContent: string, newMetadataContent: string ) { return await generateHash( JSON.stringify(rawWorkspaceDependencies) + scriptContent + newMetadataContent ); } async function computeModuleHashes( moduleFolderPath: string, defaultTs: "bun" | "deno" | undefined, rawWorkspaceDependencies: Record, isFolderLayout: boolean, ): Promise> { const hashes: Record = {}; 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 { 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+` (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, yamlOptions), "utf-8" ); } } } export async function updateMetadataGlobalLock( path: string, hash: string, subpath?: string ): Promise { 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, yamlOptions), "utf-8" ); } }