mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 16:03:47 +00:00
* fix(cli): only preserve case for raw-app runnableIds, not app/flow summaries PR #8940 stopped lowercasing in sanitizeForFilesystem to fix #8939, where a raw-app runnableId like CamelCaseTSRunnable produced a CamelCase YAML metadata file but a lowercased code file, making them desync and register as duplicate runnables on push. That fix overshot. sanitizeForFilesystem is also reached by newPathAssigner, which serves normal apps and flows where the input is the script's human summary ("Get Users Data") rather than an identifier. There the on-disk filename is the only artifact — there's no companion YAML to keep in sync — so lowercasing was the right behavior. Removing it changed both the on-disk filename and the !inline reference in app.yaml / flow.yaml from get_users_data.inline_script.ts to Get_Users_Data.inline_script.ts on the next pull, surfacing as unwanted case churn for users updating to 1.693.x. Add a preserveCase option to sanitizeForFilesystem (default false → lowercase). newRawAppPathAssigner opts in; newPathAssigner stays on the default. Update unit tests accordingly and add an end-to-end raw-app round-trip in raw_app_sync.test.ts that pushes a CamelCase backend runnable, pulls it back, and asserts both YAML and code file preserve case with no lowercase orphan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(cli): use readdir for exact-case orphan check on Windows The CamelCase round-trip test used fileExists("camelcasetsrunnable.ts") to assert no lowercase orphan was produced, which false-positives on Windows since the filesystem is case-insensitive and resolves the lookup to the existing CamelCaseTSRunnable.ts. Switch to readdir + toContain so the exact on-disk casing is compared identically on Linux and Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
7.0 KiB
TypeScript
240 lines
7.0 KiB
TypeScript
import { RawScript } from "../gen/types.gen";
|
|
|
|
const INLINE_SCRIPT_PREFIX = "inline_script";
|
|
|
|
/**
|
|
* Union type of all supported programming languages in Windmill
|
|
*/
|
|
export type SupportedLanguage = RawScript["language"] | "frontend" | "bunnative" | "oracledb" | "rust" | "csharp" | "nu" | "ansible" | "java" | "duckdb";
|
|
|
|
/**
|
|
* Mapping of supported languages to their file extensions
|
|
*/
|
|
export const LANGUAGE_EXTENSIONS: Record<SupportedLanguage, string> = {
|
|
python3: "py",
|
|
bun: "bun.ts",
|
|
deno: "deno.ts",
|
|
go: "go",
|
|
bash: "sh",
|
|
powershell: "ps1",
|
|
postgresql: "pg.sql",
|
|
mysql: "my.sql",
|
|
bigquery: "bq.sql",
|
|
oracledb: "odb.sql",
|
|
snowflake: "sf.sql",
|
|
mssql: "ms.sql",
|
|
graphql: "gql",
|
|
nativets: "native.ts",
|
|
frontend: "frontend.js",
|
|
php: "php",
|
|
rust: "rs",
|
|
csharp: "cs",
|
|
nu: "nu",
|
|
ansible: "playbook.yml",
|
|
java: "java",
|
|
duckdb: "duckdb.sql",
|
|
bunnative: "ts",
|
|
ruby: "rb",
|
|
rlang: "r",
|
|
// for related places search: ADD_NEW_LANG
|
|
};
|
|
|
|
/**
|
|
* Gets the appropriate file extension for a given programming language.
|
|
* Handles special cases for TypeScript variants based on the default runtime.
|
|
*
|
|
* @param language - The programming language to get extension for
|
|
* @param defaultTs - Default TypeScript runtime ("bun" or "deno")
|
|
* @returns File extension string (without the dot)
|
|
*/
|
|
export function getLanguageExtension(
|
|
language: SupportedLanguage,
|
|
defaultTs: "bun" | "deno" = "bun"
|
|
): string {
|
|
if (language === defaultTs || language === "bunnative") {
|
|
return "ts";
|
|
}
|
|
return LANGUAGE_EXTENSIONS[language] || "no_ext";
|
|
}
|
|
|
|
/**
|
|
* Reverse mapping from file extensions to languages.
|
|
* Used when deriving language from file extension.
|
|
*/
|
|
export const EXTENSION_TO_LANGUAGE: Record<string, SupportedLanguage> = {
|
|
"py": "python3",
|
|
"bun.ts": "bun",
|
|
"deno.ts": "deno",
|
|
"go": "go",
|
|
"sh": "bash",
|
|
"ps1": "powershell",
|
|
"pg.sql": "postgresql",
|
|
"my.sql": "mysql",
|
|
"bq.sql": "bigquery",
|
|
"odb.sql": "oracledb",
|
|
"sf.sql": "snowflake",
|
|
"ms.sql": "mssql",
|
|
"gql": "graphql",
|
|
"native.ts": "nativets",
|
|
"frontend.js": "frontend",
|
|
"php": "php",
|
|
"rs": "rust",
|
|
"cs": "csharp",
|
|
"nu": "nu",
|
|
"playbook.yml": "ansible",
|
|
"java": "java",
|
|
"duckdb.sql": "duckdb",
|
|
"rb": "ruby",
|
|
// Plain .ts defaults to bun (will be overridden by defaultTs setting)
|
|
"ts": "bun",
|
|
};
|
|
|
|
/**
|
|
* Gets the language from a file extension.
|
|
*
|
|
* @param ext - File extension (e.g., "py", "ts", "bun.ts")
|
|
* @param defaultTs - Default TypeScript runtime for plain .ts files
|
|
* @returns The language, or undefined if not recognized
|
|
*/
|
|
export function getLanguageFromExtension(
|
|
ext: string,
|
|
defaultTs: "bun" | "deno" = "bun"
|
|
): SupportedLanguage | undefined {
|
|
// Check for compound extensions first (e.g., "bun.ts", "pg.sql")
|
|
const lang = EXTENSION_TO_LANGUAGE[ext];
|
|
if (lang) {
|
|
// For plain .ts, return the default TypeScript runtime
|
|
if (ext === "ts") {
|
|
return defaultTs;
|
|
}
|
|
return lang;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a summary string for use as a filesystem-safe name.
|
|
* Removes or replaces characters that are invalid on common filesystems.
|
|
*/
|
|
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/;
|
|
|
|
export function sanitizeForFilesystem(
|
|
summary: string,
|
|
options?: { preserveCase?: boolean },
|
|
): string {
|
|
let name = summary
|
|
.replaceAll(" ", "_")
|
|
// Remove characters invalid on Windows/Unix/Mac: / \ : * ? " < > |
|
|
// Also remove control characters (0x00-0x1F) and DEL (0x7F)
|
|
// deno-lint-ignore no-control-regex
|
|
.replace(/[/\\:*?"<>|\x00-\x1f\x7f]/g, "")
|
|
// Collapse consecutive underscores
|
|
.replace(/_+/g, "_")
|
|
// Trim leading/trailing dots and underscores (hidden files, Windows edge cases)
|
|
.replace(/^[._]+|[._]+$/g, "");
|
|
if (!options?.preserveCase) {
|
|
name = name.toLowerCase();
|
|
}
|
|
// Prefix Windows reserved device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9)
|
|
return WINDOWS_RESERVED.test(name.toLowerCase()) ? `_${name}` : name;
|
|
}
|
|
|
|
export interface PathAssigner {
|
|
assignPath(summary: string | undefined, language: SupportedLanguage): [string, string];
|
|
}
|
|
|
|
export interface PathAssignerOptions {
|
|
defaultTs: "bun" | "deno";
|
|
/** When true, skip the .inline_script. suffix in file names */
|
|
skipInlineScriptSuffix?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Creates a new path assigner for inline scripts.
|
|
*
|
|
* @param defaultTs - Default TypeScript runtime ("bun" or "deno")
|
|
* @param options - Optional configuration (can pass options object instead of defaultTs)
|
|
* @returns Path assigner function
|
|
*/
|
|
export function newPathAssigner(defaultTs: "bun" | "deno" | PathAssignerOptions, options?: { skipInlineScriptSuffix?: boolean }): PathAssigner {
|
|
// Handle both old signature (defaultTs string) and new signature (options object)
|
|
const resolvedOptions: PathAssignerOptions = typeof defaultTs === "object"
|
|
? defaultTs
|
|
: { defaultTs, skipInlineScriptSuffix: options?.skipInlineScriptSuffix };
|
|
|
|
const { defaultTs: tsRuntime, skipInlineScriptSuffix } = resolvedOptions;
|
|
|
|
let counter = 0;
|
|
// Dedupe case-insensitively so case-only duplicates don't collide on
|
|
// case-insensitive filesystems (Windows, default macOS).
|
|
const seen_names = new Set<string>();
|
|
function assignPath(
|
|
summary: string | undefined,
|
|
language: SupportedLanguage
|
|
): [string, string] {
|
|
let name;
|
|
|
|
name = summary ? sanitizeForFilesystem(summary) : "";
|
|
|
|
let original_name = name;
|
|
|
|
if (name == "") {
|
|
original_name = INLINE_SCRIPT_PREFIX;
|
|
name = `${INLINE_SCRIPT_PREFIX}_0`;
|
|
}
|
|
|
|
while (seen_names.has(name.toLowerCase())) {
|
|
counter++;
|
|
name = `${original_name}_${counter}`;
|
|
}
|
|
seen_names.add(name.toLowerCase());
|
|
|
|
const ext = getLanguageExtension(language, tsRuntime);
|
|
|
|
// When skipInlineScriptSuffix is true, don't add .inline_script. to the path
|
|
const suffix = skipInlineScriptSuffix ? "." : ".inline_script.";
|
|
return [`${name}${suffix}`, ext];
|
|
}
|
|
return { assignPath };
|
|
}
|
|
|
|
/**
|
|
* Creates a new path assigner for raw app runnables.
|
|
* Unlike newPathAssigner, this does NOT add ".inline_script." prefix since
|
|
* everything in raw_app/backend/ is already known to be for inline scripts.
|
|
*
|
|
* @param defaultTs - Default TypeScript runtime ("bun" or "deno")
|
|
* @returns Path assigner function
|
|
*/
|
|
export function newRawAppPathAssigner(defaultTs: "bun" | "deno"): PathAssigner {
|
|
let counter = 0;
|
|
// Dedupe case-insensitively so case-only duplicates don't collide on
|
|
// case-insensitive filesystems (Windows, default macOS).
|
|
const seen_names = new Set<string>();
|
|
function assignPath(
|
|
summary: string | undefined,
|
|
language: SupportedLanguage
|
|
): [string, string] {
|
|
let name;
|
|
|
|
name = summary ? sanitizeForFilesystem(summary, { preserveCase: true }) : "";
|
|
|
|
let original_name = name;
|
|
|
|
if (name == "") {
|
|
original_name = "runnable";
|
|
name = `runnable_0`;
|
|
}
|
|
|
|
while (seen_names.has(name.toLowerCase())) {
|
|
counter++;
|
|
name = `${original_name}_${counter}`;
|
|
}
|
|
seen_names.add(name.toLowerCase());
|
|
|
|
const ext = getLanguageExtension(language, defaultTs);
|
|
|
|
return [`${name}.`, ext];
|
|
}
|
|
return { assignPath };
|
|
} |