blob: 3369b1d6700291d77bcf2a398ac7498e521e7807 [file] [log] [blame]
import path from "path";
import { FileSystem } from "./fs.js";
import { ServiceAnalyzer, ConfigVar, ConfigVarCategory, ConfigVarSemanticType } from "./analyze.js";
import { parse as parseDotenv } from "dotenv";
import { parsePrismaSchema } from "@loancrate/prisma-schema-parser";
import { augmentConfigVar } from "./semantics.js";
import { expandValue } from "./env.js";
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,
};
}
}