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,
+ };
+ }
+}