feat(lanes): detect docker-compose database/Redis and wire ENV_REWRITE (A3)
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+109
-5
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A name safe to embed as literal text inside a generated shell script (an
|
* A name safe to embed as literal text inside a generated shell script (an
|
||||||
@@ -46,6 +47,97 @@ function pickScript(pkg, candidates) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A repo-name-derived DB_PREFIX: lowercase alnum + underscore, "_l" suffix
|
||||||
|
* (lane N's actual name becomes "<prefix>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
|
* 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
|
* 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"));
|
const frontendPkg = readPackageJson(path.join(repoPath, "frontend"));
|
||||||
|
|
||||||
if (backendPkg && frontendPkg) {
|
if (backendPkg && frontendPkg) {
|
||||||
|
const { database, redis, env } = detectDatabaseAndEnv(repoPath, "backend");
|
||||||
return {
|
return {
|
||||||
layout: "monorepo",
|
layout: "monorepo",
|
||||||
services: {
|
services: {
|
||||||
backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) },
|
backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) },
|
||||||
frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) },
|
frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) },
|
||||||
},
|
},
|
||||||
database: null,
|
database,
|
||||||
env: null,
|
redis,
|
||||||
|
env,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPkg = readPackageJson(repoPath);
|
const rootPkg = readPackageJson(repoPath);
|
||||||
if (rootPkg) {
|
if (rootPkg) {
|
||||||
|
const { database, redis, env } = detectDatabaseAndEnv(repoPath, ".");
|
||||||
return {
|
return {
|
||||||
layout: "single-service",
|
layout: "single-service",
|
||||||
services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } },
|
services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } },
|
||||||
database: null,
|
database,
|
||||||
env: null,
|
redis,
|
||||||
|
env,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { IDENTIFIER_RE, readPackageJson, pickScript, detectNode };
|
module.exports = {
|
||||||
|
IDENTIFIER_RE,
|
||||||
|
readPackageJson,
|
||||||
|
pickScript,
|
||||||
|
dbPrefixFor,
|
||||||
|
findComposeService,
|
||||||
|
findEnvSource,
|
||||||
|
detectNode,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user