Files
windmill/cli/src/commands/app/lint.ts
T

299 lines
8.7 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import process from "node:process";
import { Command } from "@cliffy/command";
import { colors } from "@cliffy/ansi/colors";
import * as log from "../../core/log.ts";
import { yamlParseFile } from "../../utils/yaml.ts";
import { GlobalOptions } from "../../types.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
import { inferSchema } from "../../utils/metadata.ts";
import { statedIsolation } from "./app.ts";
import {
getFolderSuffix,
hasFolderSuffix,
loadNonDottedPathsSetting,
} from "../../utils/resource_folders.ts";
interface LintOptions extends GlobalOptions {
fix?: boolean;
}
interface LintResult {
valid: boolean;
errors: string[];
warnings: string[];
}
/**
* Validates the structure of raw_app.yaml
*/
function validateRawAppYaml(appData: any): {
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
// Check required fields
if (!appData.summary) {
errors.push("Missing required field: 'summary'");
} else if (typeof appData.summary !== "string") {
errors.push("Field 'summary' must be a string");
}
try {
statedIsolation(appData, true);
} catch (error: any) {
errors.push(error.message);
}
// Note: 'runnables' is no longer required in raw_app.yaml
// Runnables can be stored in separate files in the backend folder
return { errors, warnings };
}
const SQL_LANGUAGES = new Set([
"postgresql",
"mysql",
"bigquery",
"snowflake",
"mssql",
"oracledb",
"duckdb",
]);
// The viewer properties a `ctx` field can supply. The Inputs tab of the app
// editor hints on the same names (RawAppInlineScriptRunnable.svelte).
const VIEWER_IDENTITY_ARGS = ["username", "email", "groups"];
/**
* A SQL runnable argument named after a viewer property is most often a
* per-viewer filter. Left as a plain input it takes whatever the browser
* sends, so it has to be a `ctx` field for the server to fill it.
*/
export async function viewerIdentityInputWarnings(
runnables: Record<string, any>,
): Promise<string[]> {
const warnings: string[] = [];
for (const [id, runnable] of Object.entries(runnables)) {
const { language, content } = runnable?.inlineScript ?? {};
if (!SQL_LANGUAGES.has(language) || typeof content !== "string") {
continue;
}
const { schema } = await inferSchema(language, content, {}, id);
for (const arg of VIEWER_IDENTITY_ARGS) {
const field = runnable.fields?.[arg];
const serverFilled = field?.ctx !== undefined ||
field?.value !== undefined;
if (arg in (schema?.properties ?? {}) && !serverFilled) {
warnings.push(
`Runnable '${id}': argument '${arg}' is a frontend input, so a viewer can send any value. ` +
`If it identifies the viewer, set 'fields: { ${arg}: { ctx: ${arg} } }' in backend/${id}.yaml (next to 'type: inline') so the server fills it.`,
);
}
}
}
return warnings;
}
/**
* Validates that runnables exist either in backend/*.yaml files or in raw_app.yaml
*/
async function validateRunnables(
appDir: string,
appData: any,
): Promise<{ errors: string[]; warnings: string[] }> {
const errors: string[] = [];
const warnings: string[] = [];
const backendPath = path.join(appDir, APP_BACKEND_FOLDER);
// Load runnables from separate files (new format)
const runnablesFromBackend = await loadRunnablesFromBackend(backendPath);
const hasBackendRunnables = Object.keys(runnablesFromBackend).length > 0;
// Check for runnables in raw_app.yaml (old format)
const hasYamlRunnables = appData.runnables &&
typeof appData.runnables === "object" &&
!Array.isArray(appData.runnables) &&
Object.keys(appData.runnables).length > 0;
if (!hasBackendRunnables && !hasYamlRunnables) {
errors.push(
"No runnables found. Expected either:\n" +
" - Runnable YAML files in the 'backend/' folder (e.g., backend/myRunnable.yaml)\n" +
" - Or a 'runnables' field in raw_app.yaml (legacy format)",
);
} else if (hasBackendRunnables) {
warnings.push(...await viewerIdentityInputWarnings(runnablesFromBackend));
log.info(
colors.gray(
` Found ${
Object.keys(runnablesFromBackend).length
} runnable(s) in backend folder`,
),
);
} else if (hasYamlRunnables) {
log.info(
colors.gray(
` Found ${
Object.keys(appData.runnables).length
} runnable(s) in raw_app.yaml (legacy format)`,
),
);
warnings.push(
"Using legacy format with runnables in raw_app.yaml. Consider migrating to separate files in backend/",
);
}
return { errors, warnings };
}
/**
* Checks if the app can be built successfully
*/
async function validateBuild(
appDir: string,
): Promise<{ errors: string[]; warnings: string[] }> {
const errors: string[] = [];
const warnings: string[] = [];
try {
log.info(colors.blue("🔨 Testing build..."));
// Try to create a bundle - this will validate that all dependencies are in place
const frameworks = detectFrameworks(appDir);
const entryFile = frameworks.svelte ? "index.ts" : "index.tsx";
const entryPoint = path.join(appDir, entryFile);
await createBundle({
entryPoint,
production: true,
minify: false,
});
log.info(colors.green("✅ Build successful"));
} catch (error: any) {
errors.push(`Build failed: ${error.message}`);
}
return { errors, warnings };
}
/**
* Validates a raw app folder
*/
async function lintRawApp(
appDir: string,
opts: LintOptions,
): Promise<LintResult> {
const errors: string[] = [];
const warnings: string[] = [];
// Check if we're in a .raw_app folder
const currentDirName = path.basename(appDir);
if (!hasFolderSuffix(currentDirName, "raw_app")) {
errors.push(
`Not a raw app folder: '${currentDirName}' does not end with '${
getFolderSuffix("raw_app")
}'`,
);
return { valid: false, errors, warnings };
}
// Check if raw_app.yaml exists
const rawAppPath = path.join(appDir, "raw_app.yaml");
if (!fs.existsSync(rawAppPath)) {
errors.push("Missing raw_app.yaml file");
return { valid: false, errors, warnings };
}
log.info(colors.blue("📋 Validating raw_app.yaml structure..."));
// Parse and validate raw_app.yaml
let appData: any;
try {
appData = await yamlParseFile(rawAppPath);
} catch (error: any) {
errors.push(`Failed to parse raw_app.yaml: ${error.message}`);
return { valid: false, errors, warnings };
}
const yamlValidation = validateRawAppYaml(appData);
errors.push(...yamlValidation.errors);
warnings.push(...yamlValidation.warnings);
if (errors.length > 0) {
return { valid: false, errors, warnings };
}
log.info(colors.green("✅ raw_app.yaml structure is valid"));
// Validate runnables (either in backend folder or in raw_app.yaml)
log.info(colors.blue("📋 Validating runnables..."));
const runnablesValidation = await validateRunnables(appDir, appData);
errors.push(...runnablesValidation.errors);
warnings.push(...runnablesValidation.warnings);
if (errors.length > 0) {
return { valid: false, errors, warnings };
}
log.info(colors.green("✅ Runnables are valid"));
// Validate build
const buildValidation = await validateBuild(appDir);
errors.push(...buildValidation.errors);
warnings.push(...buildValidation.warnings);
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Main lint command
*/
async function lint(opts: LintOptions, appFolder?: string) {
// Load nonDottedPaths setting before using folder suffix functions
await loadNonDottedPathsSetting();
const targetDir = appFolder ?? process.cwd();
log.info(colors.bold.blue(`\n🔍 Linting raw app: ${targetDir}\n`));
const result = await lintRawApp(targetDir, opts);
// Display results
if (result.warnings.length > 0) {
log.info(colors.yellow("\n⚠️ Warnings:"));
result.warnings.forEach((warning) => {
log.info(colors.yellow(` - ${warning}`));
});
}
if (result.errors.length > 0) {
log.info(colors.red("\n❌ Errors:"));
result.errors.forEach((error) => {
log.info(colors.red(` - ${error}`));
});
log.info(colors.red("\n❌ Lint failed\n"));
process.exit(1);
}
log.info(colors.green("\n✅ All checks passed\n"));
}
const command = new Command()
.description("Lint a raw app folder to validate structure and buildability")
.arguments("[app_folder:string]")
.option("--fix", "Attempt to fix common issues (not implemented yet)")
.action(lint as any);
export default command;