From c4b11799dc09aa6292410f45710bcb57d739af8f Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 09:13:20 +0700 Subject: [PATCH] feat(lanes): detect docker-compose database/Redis and wire ENV_REWRITE (A3) --- server/__tests__/lane-detect.test.js | 64 +++++++++++++++ server/lib/lane-detect.js | 114 +++++++++++++++++++++++++-- 2 files changed, 173 insertions(+), 5 deletions(-) diff --git a/server/__tests__/lane-detect.test.js b/server/__tests__/lane-detect.test.js index 9e6eb56..af52065 100644 --- a/server/__tests__/lane-detect.test.js +++ b/server/__tests__/lane-detect.test.js @@ -107,3 +107,67 @@ describe("IDENTIFIER_RE", () => { } }); }); + +function writeCompose(repo, servicesYaml) { + fs.writeFileSync(path.join(repo, "docker-compose.yml"), `services:\n${servicesYaml}\n`); +} + +describe("detectNode: database + Redis + env wiring", () => { + it("detects a postgres service in docker-compose.yml", () => { + const repo = makeRepo(); + writePkg(path.join(repo, "backend"), { start: "node server.js" }); + writePkg(path.join(repo, "frontend"), { dev: "vite" }); + writeCompose(repo, " postgres:\n image: postgres:16\n"); + const facts = detect.detectNode(repo); + assert.equal(facts.database.dbKind, "postgres"); + assert.equal(facts.database.dbService, "postgres"); + assert.match(facts.database.dbPrefix, /_l$/); + assert.equal(facts.redis, false); + }); + + it("detects redis independently of postgres", () => { + const repo = makeRepo(); + writePkg(path.join(repo, "backend"), { start: "node server.js" }); + writePkg(path.join(repo, "frontend"), { dev: "vite" }); + writeCompose(repo, " cache_redis:\n image: redis:7\n"); + const facts = detect.detectNode(repo); + assert.equal(facts.database, null); + assert.equal(facts.redis, true); + }); + + it("finds no database when there is no docker-compose.yml", () => { + const repo = makeRepo(); + writePkg(repo, { start: "node index.js" }); + const facts = detect.detectNode(repo); + assert.equal(facts.database, null); + assert.equal(facts.redis, false); + assert.equal(facts.env, null); + }); + + it("wires ENV_REWRITE when a database is found and backend/.env.example exists", () => { + const repo = makeRepo(); + writePkg(path.join(repo, "backend"), { start: "node server.js" }); + writePkg(path.join(repo, "frontend"), { dev: "vite" }); + writeCompose(repo, " postgres:\n image: postgres:16\n redis:\n image: redis:7\n"); + fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n"); + const facts = detect.detectNode(repo); + assert.equal(facts.env.file, "backend/.env"); + assert.equal(facts.env.source, "backend/.env.example"); + assert.equal(facts.env.fromExample, true); + assert.deepEqual(facts.env.rewrite, ["DATABASE_URL", "REDIS_URL"]); + }); + + it("leaves env null when a database is found but there is no .env or .env.example", () => { + const repo = makeRepo(); + writePkg(repo, { start: "node index.js" }); + writeCompose(repo, " postgres:\n image: postgres:16\n"); + assert.equal(detect.detectNode(repo).env, null); + }); + + it("rejects a docker-compose service name that fails the shell-identifier check", () => { + const repo = makeRepo(); + writePkg(repo, { start: "node index.js" }); + writeCompose(repo, ' "postgres; rm -rf /":\n image: postgres:16\n'); + assert.equal(detect.detectNode(repo).database, null); + }); +}); diff --git a/server/lib/lane-detect.js b/server/lib/lane-detect.js index 54d89b6..f02fb25 100644 --- a/server/lib/lane-detect.js +++ b/server/lib/lane-detect.js @@ -11,6 +11,7 @@ const fs = require("node:fs"); const path = require("node:path"); +const yaml = require("js-yaml"); /** * A name safe to embed as literal text inside a generated shell script (an @@ -46,6 +47,97 @@ function pickScript(pkg, candidates) { return null; } +/** + * A repo-name-derived DB_PREFIX: lowercase alnum + underscore, "_l" suffix + * (lane N's actual name becomes "N"), matching the convention in + * the postgres-compose profile template's example. + * + * @param {string} repoPath - Absolute path to the repository root. + * @returns {string} + */ +function dbPrefixFor(repoPath) { + const base = path + .basename(repoPath) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return `${base || "app"}_l`; +} + +/** + * The first docker-compose service name matching `pattern`, or null. Reads + * `docker-compose.yml`/`.yaml` at the repo root; never executes it. + * + * @param {string} repoPath - Absolute path to the repository root. + * @param {RegExp} pattern - Pattern to match service name. + * @returns {string|null} + */ +function findComposeService(repoPath, pattern) { + for (const name of ["docker-compose.yml", "docker-compose.yaml"]) { + let doc; + try { + doc = yaml.load(fs.readFileSync(path.join(repoPath, name), "utf8")); + } catch { + continue; + } + const services = doc && typeof doc.services === "object" ? doc.services : {}; + for (const serviceName of Object.keys(services)) { + if (pattern.test(serviceName) && IDENTIFIER_RE.test(serviceName)) return serviceName; + } + } + return null; +} + +/** + * Where a lane's .env lives for this layout: backend/.env in a monorepo, + * .env at the root for single-service. Returns the source to seed FROM + * (real .env, else .env.example) or null when neither exists. + * + * @param {string} repoPath - Absolute path to the repository root. + * @param {string} envDir - Directory where .env lives (e.g. "backend" or "."). + * @returns {object|null} + */ +function findEnvSource(repoPath, envDir) { + const dir = path.join(repoPath, envDir); + const real = path.join(dir, ".env"); + const example = path.join(dir, ".env.example"); + const relFile = path.join(envDir, ".env").replace(/\\/g, "/").replace(/^\.\//, ""); + if (fs.existsSync(real)) return { file: relFile, source: relFile, fromExample: false }; + if (fs.existsSync(example)) { + return { file: relFile, source: `${relFile}.example`, fromExample: true }; + } + return null; +} + +/** + * Detect database and Redis services from docker-compose.yml and wire up + * .env rewriting if services are found. + * + * @param {string} repoPath - Absolute path to the repository root. + * @param {string} envDir - Directory where .env lives (e.g. "backend" or "."). + * @returns {object} + */ +function detectDatabaseAndEnv(repoPath, envDir) { + const dbService = findComposeService(repoPath, /postgres/i); + const redis = findComposeService(repoPath, /redis/i) !== null; + const database = dbService + ? { dbPrefix: dbPrefixFor(repoPath), dbService, dbKind: "postgres" } + : null; + + let env = null; + if (database || redis) { + const found = findEnvSource(repoPath, envDir); + if (found) { + const rewrite = []; + if (database) rewrite.push("DATABASE_URL"); + if (redis) rewrite.push("REDIS_URL"); + env = { dir: envDir, ...found, rewrite }; + } + } + + return { database, redis, env }; +} + /** * Detect a Node.js project's layout and, for each service, which npm script * to run. Returns null when neither supported layout matches — the caller @@ -59,28 +151,40 @@ function detectNode(repoPath) { const frontendPkg = readPackageJson(path.join(repoPath, "frontend")); if (backendPkg && frontendPkg) { + const { database, redis, env } = detectDatabaseAndEnv(repoPath, "backend"); return { layout: "monorepo", services: { backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) }, frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) }, }, - database: null, - env: null, + database, + redis, + env, }; } const rootPkg = readPackageJson(repoPath); if (rootPkg) { + const { database, redis, env } = detectDatabaseAndEnv(repoPath, "."); return { layout: "single-service", services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } }, - database: null, - env: null, + database, + redis, + env, }; } return null; } -module.exports = { IDENTIFIER_RE, readPackageJson, pickScript, detectNode }; +module.exports = { + IDENTIFIER_RE, + readPackageJson, + pickScript, + dbPrefixFor, + findComposeService, + findEnvSource, + detectNode, +};