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