Cavnas: Implement basic service discovery logic

Change-Id: I71b25076dba94d6491ad4db748b259870991c526
diff --git a/apps/canvas/back/src/lib/nodejs.ts b/apps/canvas/back/src/lib/nodejs.ts
new file mode 100644
index 0000000..07e6c1f
--- /dev/null
+++ b/apps/canvas/back/src/lib/nodejs.ts
@@ -0,0 +1,280 @@
+import path from "path";
+import { FileSystem } from "./fs";
+import { ServiceAnalyzer, ConfigVar, ConfigVarCategory, ConfigVarSemanticType } from "./analyze";
+import { parse as parseDotenv } from "dotenv";
+import { parsePrismaSchema } from "@loancrate/prisma-schema-parser";
+import { augmentConfigVar } from "./semantics";
+import { expandValue } from "./env";
+import { z } from "zod";
+
+const packageJsonFileName = "package.json";
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const packageJsonSchema = z.object({
+	name: z.optional(z.string()),
+	version: z.optional(z.string()),
+	engines: z.optional(
+		z.object({
+			node: z.optional(z.string()),
+			deno: z.optional(z.string()),
+		}),
+	),
+	dependencies: z.optional(z.record(z.string(), z.string())),
+	devDependencies: z.optional(z.record(z.string(), z.string())),
+});
+
+type PackageJson = z.infer<typeof packageJsonSchema>;
+
+interface ConfigVarDetector {
+	(fs: FileSystem, root: string, packageJson: PackageJson): Promise<ConfigVar | ConfigVar[] | null>;
+}
+
+// TODO(gio): add bun, deno, ...
+type NodeJSPackageManager =
+	| {
+			name: "npm";
+			version?: string;
+	  }
+	| {
+			name: "pnpm";
+			version?: string;
+	  }
+	| {
+			name: "yarn";
+			version?: string;
+	  };
+
+type Runtime =
+	| {
+			name: "node";
+			version?: string;
+	  }
+	| {
+			name: "deno";
+			version?: string;
+	  };
+
+const defaultRuntime: Runtime = {
+	name: "node",
+};
+
+const defaultPackageManager: NodeJSPackageManager = {
+	name: "npm",
+};
+
+export class NodeJSAnalyzer implements ServiceAnalyzer {
+	detect(fs: FileSystem, root: string) {
+		const packageJsonPath = path.join(root, packageJsonFileName);
+		if (!fs.exists(packageJsonPath)) {
+			return false;
+		}
+		// TODO(gio): maybe it's deno
+		return true;
+	}
+
+	async analyze(fs: FileSystem, root: string) {
+		const packageJsonPath = path.join(root, packageJsonFileName);
+		const packageJson = JSON.parse(await fs.readFile(packageJsonPath));
+		const runtime = this.detectRuntime(packageJson);
+		const packageManager = this.detectPackageManager(fs, root);
+		console.log(runtime, packageManager);
+		let envVars = await this.detectEnvVars(fs, root);
+		const detectors: ConfigVarDetector[] = [this.detectPrismaSchema, this.detectNextjs, this.detectExpressjs];
+		const all = await Promise.all(
+			detectors.map(async (detector) => {
+				return await detector(fs, root, packageJson);
+			}),
+		);
+		all.map((cv) => {
+			if (Array.isArray(cv)) {
+				cv.forEach((v) => this.mergeConfigVars(envVars, v));
+			} else {
+				this.mergeConfigVars(envVars, cv);
+			}
+		});
+		envVars = envVars.filter((v) => v.semanticType != ConfigVarSemanticType.EXPANDED_ENV_VAR);
+		envVars.forEach((v) => augmentConfigVar(v));
+		return {
+			name: "name" in packageJson ? packageJson.name : "NodeJS",
+			location: root,
+			configVars: envVars,
+			commands: [],
+		};
+	}
+
+	private mergeConfigVars(configVars: ConfigVar[], v: ConfigVar | null) {
+		if (v == null) {
+			return;
+		}
+		const existing = configVars.find((c) => c.name === v.name);
+		if (existing != null) {
+			existing.category = existing.category ?? v.category;
+			existing.semanticType = existing.semanticType ?? v.semanticType;
+			existing.defaultValue = existing.defaultValue ?? v.defaultValue;
+			existing.description = existing.description ?? v.description;
+			existing.required = existing.required ?? v.required;
+			existing.sensitive = v.sensitive;
+		} else {
+			configVars.push(v);
+		}
+	}
+
+	private detectRuntime(packageJson: PackageJson): Runtime {
+		if (packageJson.engines && packageJson.engines.node) {
+			return {
+				name: "node",
+				version: packageJson.engines.node,
+			};
+		} else if (packageJson.engines && packageJson.engines.deno) {
+			return {
+				name: "deno",
+				version: packageJson.engines.deno,
+			};
+		}
+		return defaultRuntime;
+	}
+
+	private detectPackageManager(fs: FileSystem, root: string): NodeJSPackageManager | null {
+		if (fs.exists(path.join(root, "package-lock.yaml"))) {
+			return {
+				name: "npm",
+			};
+		} else if (fs.exists(path.join(root, "pnpm-lock.yaml"))) {
+			return {
+				name: "pnpm",
+			};
+		} else if (fs.exists(path.join(root, "yarn.lock"))) {
+			return {
+				name: "yarn",
+			};
+		}
+		return defaultPackageManager;
+	}
+
+	private async detectEnvVars(fs: FileSystem, root: string): Promise<ConfigVar[]> {
+		const envFilePath = path.join(root, ".env");
+		if (!fs.exists(envFilePath)) {
+			return [];
+		}
+		const envVars: ConfigVar[] = [];
+		const fileContent = await fs.readFile(envFilePath);
+		const parsedEnv = parseDotenv(fileContent);
+		for (const key in parsedEnv) {
+			if (Object.prototype.hasOwnProperty.call(parsedEnv, key)) {
+				const defaultValue = parsedEnv[key];
+				const vars = expandValue(defaultValue);
+				envVars.push({
+					name: key,
+					defaultValue,
+					category: ConfigVarCategory.EnvironmentVariable,
+					semanticType: ConfigVarSemanticType.EXPANDED_ENV_VAR,
+				});
+				vars.forEach((v) => {
+					envVars.push({
+						name: v,
+						defaultValue: "", // TODO(gio): add default value
+						category: ConfigVarCategory.EnvironmentVariable,
+					});
+				});
+			}
+		}
+		return envVars;
+	}
+
+	private async detectPrismaSchema(
+		fs: FileSystem,
+		root: string,
+		packageJson: PackageJson,
+	): Promise<ConfigVar | ConfigVar[] | null> {
+		if (packageJson?.dependencies?.prisma == null && packageJson?.devDependencies?.prisma == null) {
+			return null;
+		}
+		let schemaPath = path.join(root, "prisma", "schema.prisma");
+		if (!fs.exists(schemaPath)) {
+			schemaPath = path.join(root, "schema.prisma");
+			if (!fs.exists(schemaPath)) {
+				return null;
+			}
+		}
+		const schemaContent = await fs.readFile(schemaPath);
+		const ast = parsePrismaSchema(schemaContent);
+		let urlVar: string | null = null;
+		let dbType: ConfigVarSemanticType | null = null;
+		for (const element of ast.declarations) {
+			if (element.kind === "datasource") {
+				for (const prop of element.members) {
+					if (prop.kind === "config") {
+						switch (prop.name.value) {
+							case "url": {
+								if (
+									prop.value.kind === "functionCall" &&
+									prop.value.path.value[0] === "env" &&
+									prop.value.args != null
+								) {
+									const arg = prop.value.args[0];
+									if (arg.kind === "literal" && typeof arg.value === "string") {
+										urlVar = arg.value;
+									}
+								}
+								break;
+							}
+							case "provider": {
+								if (prop.value.kind === "literal" && typeof prop.value.value === "string") {
+									switch (prop.value.value) {
+										case "postgresql": {
+											dbType = ConfigVarSemanticType.POSTGRES_URL;
+											break;
+										}
+										case "sqlite": {
+											dbType = ConfigVarSemanticType.SQLITE_PATH;
+											break;
+										}
+										default: {
+											throw new Error(`Unsupported database type: ${prop.value.value}`);
+										}
+									}
+								}
+								break;
+							}
+						}
+					}
+				}
+			}
+		}
+		if (urlVar == null || dbType == null) {
+			return null;
+		}
+		return {
+			name: urlVar,
+			category: ConfigVarCategory.EnvironmentVariable,
+			semanticType: dbType,
+		};
+	}
+
+	private async detectNextjs(fs: FileSystem, root: string): Promise<ConfigVar | ConfigVar[] | null> {
+		const nextConfigPath = path.join(root, "next.config.mjs");
+		if (!fs.exists(nextConfigPath)) {
+			return null;
+		}
+		return {
+			name: "PORT",
+			category: ConfigVarCategory.EnvironmentVariable,
+			semanticType: ConfigVarSemanticType.PORT,
+		};
+	}
+
+	private async detectExpressjs(
+		fs: FileSystem,
+		root: string,
+		packageJson: PackageJson,
+	): Promise<ConfigVar | ConfigVar[] | null> {
+		if (packageJson?.dependencies?.express == null && packageJson?.devDependencies?.express == null) {
+			return null;
+		}
+		return {
+			name: "PORT",
+			category: ConfigVarCategory.EnvironmentVariable,
+			semanticType: ConfigVarSemanticType.PORT,
+		};
+	}
+}