blob: 07e6c1f14c531a3dea6b1cc63b5718a6ac2a8a2c [file] [log] [blame]
gioa71316d2025-05-24 09:41:36 +04001import path from "path";
2import { FileSystem } from "./fs";
3import { ServiceAnalyzer, ConfigVar, ConfigVarCategory, ConfigVarSemanticType } from "./analyze";
4import { parse as parseDotenv } from "dotenv";
5import { parsePrismaSchema } from "@loancrate/prisma-schema-parser";
6import { augmentConfigVar } from "./semantics";
7import { expandValue } from "./env";
8import { z } from "zod";
9
10const packageJsonFileName = "package.json";
11
12// eslint-disable-next-line @typescript-eslint/no-unused-vars
13const packageJsonSchema = z.object({
14 name: z.optional(z.string()),
15 version: z.optional(z.string()),
16 engines: z.optional(
17 z.object({
18 node: z.optional(z.string()),
19 deno: z.optional(z.string()),
20 }),
21 ),
22 dependencies: z.optional(z.record(z.string(), z.string())),
23 devDependencies: z.optional(z.record(z.string(), z.string())),
24});
25
26type PackageJson = z.infer<typeof packageJsonSchema>;
27
28interface ConfigVarDetector {
29 (fs: FileSystem, root: string, packageJson: PackageJson): Promise<ConfigVar | ConfigVar[] | null>;
30}
31
32// TODO(gio): add bun, deno, ...
33type NodeJSPackageManager =
34 | {
35 name: "npm";
36 version?: string;
37 }
38 | {
39 name: "pnpm";
40 version?: string;
41 }
42 | {
43 name: "yarn";
44 version?: string;
45 };
46
47type Runtime =
48 | {
49 name: "node";
50 version?: string;
51 }
52 | {
53 name: "deno";
54 version?: string;
55 };
56
57const defaultRuntime: Runtime = {
58 name: "node",
59};
60
61const defaultPackageManager: NodeJSPackageManager = {
62 name: "npm",
63};
64
65export class NodeJSAnalyzer implements ServiceAnalyzer {
66 detect(fs: FileSystem, root: string) {
67 const packageJsonPath = path.join(root, packageJsonFileName);
68 if (!fs.exists(packageJsonPath)) {
69 return false;
70 }
71 // TODO(gio): maybe it's deno
72 return true;
73 }
74
75 async analyze(fs: FileSystem, root: string) {
76 const packageJsonPath = path.join(root, packageJsonFileName);
77 const packageJson = JSON.parse(await fs.readFile(packageJsonPath));
78 const runtime = this.detectRuntime(packageJson);
79 const packageManager = this.detectPackageManager(fs, root);
80 console.log(runtime, packageManager);
81 let envVars = await this.detectEnvVars(fs, root);
82 const detectors: ConfigVarDetector[] = [this.detectPrismaSchema, this.detectNextjs, this.detectExpressjs];
83 const all = await Promise.all(
84 detectors.map(async (detector) => {
85 return await detector(fs, root, packageJson);
86 }),
87 );
88 all.map((cv) => {
89 if (Array.isArray(cv)) {
90 cv.forEach((v) => this.mergeConfigVars(envVars, v));
91 } else {
92 this.mergeConfigVars(envVars, cv);
93 }
94 });
95 envVars = envVars.filter((v) => v.semanticType != ConfigVarSemanticType.EXPANDED_ENV_VAR);
96 envVars.forEach((v) => augmentConfigVar(v));
97 return {
98 name: "name" in packageJson ? packageJson.name : "NodeJS",
99 location: root,
100 configVars: envVars,
101 commands: [],
102 };
103 }
104
105 private mergeConfigVars(configVars: ConfigVar[], v: ConfigVar | null) {
106 if (v == null) {
107 return;
108 }
109 const existing = configVars.find((c) => c.name === v.name);
110 if (existing != null) {
111 existing.category = existing.category ?? v.category;
112 existing.semanticType = existing.semanticType ?? v.semanticType;
113 existing.defaultValue = existing.defaultValue ?? v.defaultValue;
114 existing.description = existing.description ?? v.description;
115 existing.required = existing.required ?? v.required;
116 existing.sensitive = v.sensitive;
117 } else {
118 configVars.push(v);
119 }
120 }
121
122 private detectRuntime(packageJson: PackageJson): Runtime {
123 if (packageJson.engines && packageJson.engines.node) {
124 return {
125 name: "node",
126 version: packageJson.engines.node,
127 };
128 } else if (packageJson.engines && packageJson.engines.deno) {
129 return {
130 name: "deno",
131 version: packageJson.engines.deno,
132 };
133 }
134 return defaultRuntime;
135 }
136
137 private detectPackageManager(fs: FileSystem, root: string): NodeJSPackageManager | null {
138 if (fs.exists(path.join(root, "package-lock.yaml"))) {
139 return {
140 name: "npm",
141 };
142 } else if (fs.exists(path.join(root, "pnpm-lock.yaml"))) {
143 return {
144 name: "pnpm",
145 };
146 } else if (fs.exists(path.join(root, "yarn.lock"))) {
147 return {
148 name: "yarn",
149 };
150 }
151 return defaultPackageManager;
152 }
153
154 private async detectEnvVars(fs: FileSystem, root: string): Promise<ConfigVar[]> {
155 const envFilePath = path.join(root, ".env");
156 if (!fs.exists(envFilePath)) {
157 return [];
158 }
159 const envVars: ConfigVar[] = [];
160 const fileContent = await fs.readFile(envFilePath);
161 const parsedEnv = parseDotenv(fileContent);
162 for (const key in parsedEnv) {
163 if (Object.prototype.hasOwnProperty.call(parsedEnv, key)) {
164 const defaultValue = parsedEnv[key];
165 const vars = expandValue(defaultValue);
166 envVars.push({
167 name: key,
168 defaultValue,
169 category: ConfigVarCategory.EnvironmentVariable,
170 semanticType: ConfigVarSemanticType.EXPANDED_ENV_VAR,
171 });
172 vars.forEach((v) => {
173 envVars.push({
174 name: v,
175 defaultValue: "", // TODO(gio): add default value
176 category: ConfigVarCategory.EnvironmentVariable,
177 });
178 });
179 }
180 }
181 return envVars;
182 }
183
184 private async detectPrismaSchema(
185 fs: FileSystem,
186 root: string,
187 packageJson: PackageJson,
188 ): Promise<ConfigVar | ConfigVar[] | null> {
189 if (packageJson?.dependencies?.prisma == null && packageJson?.devDependencies?.prisma == null) {
190 return null;
191 }
192 let schemaPath = path.join(root, "prisma", "schema.prisma");
193 if (!fs.exists(schemaPath)) {
194 schemaPath = path.join(root, "schema.prisma");
195 if (!fs.exists(schemaPath)) {
196 return null;
197 }
198 }
199 const schemaContent = await fs.readFile(schemaPath);
200 const ast = parsePrismaSchema(schemaContent);
201 let urlVar: string | null = null;
202 let dbType: ConfigVarSemanticType | null = null;
203 for (const element of ast.declarations) {
204 if (element.kind === "datasource") {
205 for (const prop of element.members) {
206 if (prop.kind === "config") {
207 switch (prop.name.value) {
208 case "url": {
209 if (
210 prop.value.kind === "functionCall" &&
211 prop.value.path.value[0] === "env" &&
212 prop.value.args != null
213 ) {
214 const arg = prop.value.args[0];
215 if (arg.kind === "literal" && typeof arg.value === "string") {
216 urlVar = arg.value;
217 }
218 }
219 break;
220 }
221 case "provider": {
222 if (prop.value.kind === "literal" && typeof prop.value.value === "string") {
223 switch (prop.value.value) {
224 case "postgresql": {
225 dbType = ConfigVarSemanticType.POSTGRES_URL;
226 break;
227 }
228 case "sqlite": {
229 dbType = ConfigVarSemanticType.SQLITE_PATH;
230 break;
231 }
232 default: {
233 throw new Error(`Unsupported database type: ${prop.value.value}`);
234 }
235 }
236 }
237 break;
238 }
239 }
240 }
241 }
242 }
243 }
244 if (urlVar == null || dbType == null) {
245 return null;
246 }
247 return {
248 name: urlVar,
249 category: ConfigVarCategory.EnvironmentVariable,
250 semanticType: dbType,
251 };
252 }
253
254 private async detectNextjs(fs: FileSystem, root: string): Promise<ConfigVar | ConfigVar[] | null> {
255 const nextConfigPath = path.join(root, "next.config.mjs");
256 if (!fs.exists(nextConfigPath)) {
257 return null;
258 }
259 return {
260 name: "PORT",
261 category: ConfigVarCategory.EnvironmentVariable,
262 semanticType: ConfigVarSemanticType.PORT,
263 };
264 }
265
266 private async detectExpressjs(
267 fs: FileSystem,
268 root: string,
269 packageJson: PackageJson,
270 ): Promise<ConfigVar | ConfigVar[] | null> {
271 if (packageJson?.dependencies?.express == null && packageJson?.devDependencies?.express == null) {
272 return null;
273 }
274 return {
275 name: "PORT",
276 category: ConfigVarCategory.EnvironmentVariable,
277 semanticType: ConfigVarSemanticType.PORT,
278 };
279 }
280}