bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

bzl-instances-update.js (13373B)


      1 const fs = require("fs");
      2 const path = require("path");
      3 const { spawnSync } = require("child_process");
      4 
      5 const BZL_COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
      6 const DEFAULT_MAX_DEPTH = 4;
      7 const SKIP_DIR_NAMES = new Set([
      8   ".git",
      9   "node_modules",
     10   "data",
     11   "dist",
     12   "clean_install",
     13   "multi_instance",
     14   "tmp",
     15   "temp",
     16   "proc",
     17   "sys",
     18   "dev",
     19   "run",
     20   "usr",
     21   "lib",
     22   "lib64",
     23   "bin",
     24   "sbin",
     25   "etc",
     26   "boot",
     27   "mnt",
     28   "media",
     29   "snap",
     30   "lost+found",
     31   "__pycache__"
     32 ]);
     33 
     34 function log(msg) {
     35   console.log(`[instances] ${msg}`);
     36 }
     37 
     38 function warn(msg) {
     39   console.warn(`[instances] WARN: ${msg}`);
     40 }
     41 
     42 function fail(msg) {
     43   console.error(`[instances] ERROR: ${msg}`);
     44   process.exit(1);
     45 }
     46 
     47 function parseArgs() {
     48   const out = {};
     49   for (const raw of process.argv.slice(2)) {
     50     const a = String(raw || "");
     51     if (!a.startsWith("--")) continue;
     52     const eq = a.indexOf("=");
     53     if (eq > -1) out[a.slice(2, eq)] = a.slice(eq + 1);
     54     else out[a.slice(2)] = "1";
     55   }
     56   return out;
     57 }
     58 
     59 function expandHome(value) {
     60   const v = String(value || "").trim();
     61   if (!v.startsWith("~")) return v;
     62   const home = process.env.HOME || process.env.USERPROFILE || "";
     63   if (!home) return v;
     64   if (v === "~") return home;
     65   if (v.startsWith("~/") || v.startsWith("~\\")) return path.join(home, v.slice(2));
     66   return v;
     67 }
     68 
     69 function displayPath(filePath) {
     70   const abs = path.resolve(filePath);
     71   const rel = path.relative(process.cwd(), abs);
     72   if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/");
     73   return abs.replace(/\\/g, "/");
     74 }
     75 
     76 function splitList(raw, fallback) {
     77   const text = String(raw || "").trim();
     78   if (!text) return fallback;
     79   return text
     80     .split(",")
     81     .map((x) => expandHome(String(x || "").trim()))
     82     .filter(Boolean);
     83 }
     84 
     85 function shouldSkipDir(name) {
     86   const n = String(name || "").trim().toLowerCase();
     87   if (!n) return true;
     88   return SKIP_DIR_NAMES.has(n);
     89 }
     90 
     91 function isExistingFile(filePath) {
     92   try {
     93     return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
     94   } catch {
     95     return false;
     96   }
     97 }
     98 
     99 function isExistingDir(dirPath) {
    100   try {
    101     return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
    102   } catch {
    103     return false;
    104   }
    105 }
    106 
    107 function readPackageJson(dir) {
    108   try {
    109     const pkgPath = path.join(dir, "package.json");
    110     return JSON.parse(fs.readFileSync(pkgPath, "utf8"));
    111   } catch {
    112     return null;
    113   }
    114 }
    115 
    116 function looksLikeBzlProject(pkg) {
    117   const p = pkg && typeof pkg === "object" ? pkg : null;
    118   if (!p) return false;
    119   const name = String(p?.name || "").trim().toLowerCase();
    120   if (name.includes("bzl")) return true;
    121   const scripts = p?.scripts && typeof p.scripts === "object" ? p.scripts : {};
    122   const startScript = String(scripts.start || "").trim().toLowerCase();
    123   if (startScript.includes("server.js")) return true;
    124   if (Object.prototype.hasOwnProperty.call(scripts, "create-user")) return true;
    125   const deps = p?.dependencies && typeof p.dependencies === "object" ? p.dependencies : {};
    126   if (Object.prototype.hasOwnProperty.call(deps, "ws") && Object.prototype.hasOwnProperty.call(deps, "sanitize-html")) {
    127     return true;
    128   }
    129   return false;
    130 }
    131 
    132 function looksLikeBzlText(value) {
    133   const text = String(value || "").trim().toLowerCase();
    134   return text.includes("bzl");
    135 }
    136 
    137 function sourcePriority(source) {
    138   const s = String(source || "").trim().toLowerCase();
    139   if (s === "docker-label") return 2;
    140   return 1;
    141 }
    142 
    143 function normalizeDockerValue(value) {
    144   const text = String(value || "").trim();
    145   if (!text || text === "<no value>" || text === "<nil>") return "";
    146   return text;
    147 }
    148 
    149 function composeCandidatesFromLabel(workingDir, rawConfigFiles) {
    150   const cfg = String(rawConfigFiles || "").trim();
    151   if (!cfg) return [];
    152   const candidates = [];
    153   const tokens = cfg
    154     .split(/[;,]/)
    155     .map((x) => normalizeDockerValue(x))
    156     .filter(Boolean);
    157   for (const token of tokens) {
    158     const full = path.isAbsolute(token) ? token : path.resolve(workingDir, token);
    159     if (isExistingFile(full) && !candidates.includes(full)) candidates.push(full);
    160   }
    161   return candidates;
    162 }
    163 
    164 function findComposeFile(dir) {
    165   for (const name of BZL_COMPOSE_FILES) {
    166     const full = path.join(dir, name);
    167     if (isExistingFile(full)) return full;
    168   }
    169   return "";
    170 }
    171 
    172 function detectBzlInstance(dir, options = {}) {
    173   try {
    174     const absDir = path.resolve(dir);
    175     let composeFile = String(options.preferredComposeFile || "").trim();
    176     if (composeFile) composeFile = path.resolve(absDir, composeFile);
    177     if (!isExistingFile(composeFile)) composeFile = "";
    178     if (!composeFile) composeFile = findComposeFile(absDir);
    179     if (!composeFile) return null;
    180 
    181     const source = String(options.source || "filesystem").trim() || "filesystem";
    182     const allowComposeOnly = options.allowComposeOnly === true;
    183     const hints = Array.isArray(options.hints) ? options.hints : [];
    184 
    185     const hasServer = isExistingFile(path.join(absDir, "server.js"));
    186     const hasPkg = isExistingFile(path.join(absDir, "package.json"));
    187     const pkg = hasPkg ? readPackageJson(absDir) : null;
    188 
    189     const packageHint = hasPkg && looksLikeBzlProject(pkg);
    190     const pathHint = looksLikeBzlText(absDir) || looksLikeBzlText(path.basename(absDir));
    191     const externalHint = hints.some((hint) => looksLikeBzlText(hint));
    192     const hasSource = hasServer && hasPkg;
    193     const allowBySource = hasSource && (packageHint || pathHint || externalHint);
    194     const allowByComposeOnly = allowComposeOnly && (pathHint || externalHint);
    195     if (!allowBySource && !allowByComposeOnly) return null;
    196 
    197     return {
    198       rootDir: absDir,
    199       composeFile: path.resolve(composeFile),
    200       source,
    201       hasSource
    202     };
    203   } catch {
    204     return null;
    205   }
    206 }
    207 
    208 function discoverFromRoot(rootDir, maxDepth) {
    209   const found = [];
    210   const queue = [{ dir: rootDir, depth: 0 }];
    211   const seen = new Set();
    212   while (queue.length) {
    213     const current = queue.shift();
    214     const abs = path.resolve(current.dir);
    215     if (seen.has(abs)) continue;
    216     seen.add(abs);
    217 
    218     const instance = detectBzlInstance(abs, {
    219       source: "filesystem",
    220       allowComposeOnly: true,
    221       hints: [abs]
    222     });
    223     if (instance) {
    224       found.push(instance);
    225       continue;
    226     }
    227     if (current.depth >= maxDepth) continue;
    228 
    229     let entries = [];
    230     try {
    231       entries = fs.readdirSync(abs, { withFileTypes: true });
    232     } catch {
    233       continue;
    234     }
    235     for (const entry of entries) {
    236       if (!entry.isDirectory()) continue;
    237       if (entry.isSymbolicLink()) continue;
    238       if (shouldSkipDir(entry.name)) continue;
    239       queue.push({ dir: path.join(abs, entry.name), depth: current.depth + 1 });
    240     }
    241   }
    242   return found;
    243 }
    244 
    245 function discoverFromDockerLabels() {
    246   try {
    247     const format = [
    248       "{{.Names}}",
    249       "{{.Image}}",
    250       '{{.Label "com.docker.compose.project"}}',
    251       '{{.Label "com.docker.compose.service"}}',
    252       '{{.Label "com.docker.compose.project.working_dir"}}',
    253       '{{.Label "com.docker.compose.project.config_files"}}'
    254     ].join("|");
    255     const psRes = spawnSync("docker", ["ps", "-a", "--format", format], {
    256       cwd: process.cwd(),
    257       stdio: ["ignore", "pipe", "pipe"],
    258       encoding: "utf8",
    259       shell: false
    260     });
    261     if (psRes.error || (psRes.status || 0) !== 0) return [];
    262 
    263     const found = [];
    264     const lines = String(psRes.stdout || "")
    265       .split(/\r?\n/)
    266       .map((line) => String(line || "").trim())
    267       .filter(Boolean);
    268 
    269     for (const line of lines) {
    270       const parts = line.split("|");
    271       if (parts.length < 6) continue;
    272       const containerName = normalizeDockerValue(parts[0]);
    273       const imageName = normalizeDockerValue(parts[1]);
    274       const projectName = normalizeDockerValue(parts[2]);
    275       const serviceName = normalizeDockerValue(parts[3]);
    276       const workingDirRaw = normalizeDockerValue(parts[4]);
    277       const configFilesRaw = normalizeDockerValue(parts[5]);
    278       if (!workingDirRaw) continue;
    279 
    280       const workingDir = path.resolve(workingDirRaw);
    281       if (!isExistingDir(workingDir)) continue;
    282 
    283       const composeCandidates = composeCandidatesFromLabel(workingDir, configFilesRaw);
    284       const preferredComposeFile = composeCandidates.length ? composeCandidates[0] : "";
    285       const detected = detectBzlInstance(workingDir, {
    286         source: "docker-label",
    287         allowComposeOnly: true,
    288         preferredComposeFile,
    289         hints: [containerName, imageName, projectName, serviceName, workingDir]
    290       });
    291       if (detected) {
    292         found.push(detected);
    293       }
    294     }
    295     return found;
    296   } catch {
    297     return [];
    298   }
    299 }
    300 
    301 function run(cmd, args, cwd, { dryRun = false, allowFail = false } = {}) {
    302   const printable = `${cmd} ${args.join(" ")}`;
    303   if (dryRun) {
    304     log(`[dry-run] ${printable} (cwd=${displayPath(cwd)})`);
    305     return 0;
    306   }
    307   const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false });
    308   if (result.error) {
    309     if (allowFail) {
    310       warn(`${printable} failed to start: ${result.error?.message || result.error}`);
    311       return 1;
    312     }
    313     fail(`${printable} failed to start: ${result.error?.message || result.error}`);
    314   }
    315   const code = result.status || 0;
    316   if (code !== 0 && !allowFail) fail(`${printable} failed with exit code ${code}`);
    317   return code;
    318 }
    319 
    320 function defaultRoots() {
    321   const list = [];
    322   const cwd = process.cwd();
    323   list.push(cwd);
    324   if (process.platform !== "win32") {
    325     list.push("/", "/root", "/home", "/opt", "/srv", "/var/www");
    326   } else {
    327     const driveRoot = path.parse(cwd).root;
    328     if (driveRoot) list.push(driveRoot);
    329   }
    330   const uniq = [];
    331   for (const item of list) {
    332     const abs = path.resolve(expandHome(item));
    333     if (!fs.existsSync(abs)) continue;
    334     if (uniq.includes(abs)) continue;
    335     uniq.push(abs);
    336   }
    337   return uniq;
    338 }
    339 
    340 function printDiscovered(instances) {
    341   if (!instances.length) {
    342     log("No Bzl instances discovered.");
    343     return;
    344   }
    345   log(`Discovered ${instances.length} instance(s):`);
    346   for (const [i, inst] of instances.entries()) {
    347     const source = String(inst?.source || "filesystem");
    348     console.log(
    349       `  ${i + 1}. ${displayPath(inst.rootDir)}  (compose: ${path.basename(inst.composeFile)}, source: ${source})`
    350     );
    351   }
    352 }
    353 
    354 function main() {
    355   const args = parseArgs();
    356   const update = args.update === "1";
    357   const skipGit = args["skip-git"] === "1";
    358   const skipBuild = args["skip-build"] === "1";
    359   const dryRun = args["dry-run"] === "1";
    360   const noDockerDiscovery = args["no-docker-discovery"] === "1";
    361   const remote = String(args.remote || "origin").trim();
    362   const branch = String(args.branch || "main").trim();
    363   const maxDepth = Number.isInteger(Number(args["max-depth"])) ? Math.max(1, Number(args["max-depth"])) : DEFAULT_MAX_DEPTH;
    364   const roots = splitList(args.roots, defaultRoots())
    365     .map((r) => path.resolve(r))
    366     .filter((r, idx, arr) => fs.existsSync(r) && arr.indexOf(r) === idx);
    367 
    368   if (!roots.length) fail("No valid search roots. Use --roots=/path1,/path2");
    369 
    370   log(`Scanning roots: ${roots.map((r) => displayPath(r)).join(", ")} (max-depth=${maxDepth})`);
    371 
    372   const foundRaw = [];
    373   for (const root of roots) foundRaw.push(...discoverFromRoot(root, maxDepth));
    374   if (!noDockerDiscovery) foundRaw.push(...discoverFromDockerLabels());
    375 
    376   const byRoot = new Map();
    377   for (const inst of foundRaw) {
    378     const key = path.resolve(inst.rootDir);
    379     const existing = byRoot.get(key);
    380     if (!existing || sourcePriority(inst.source) > sourcePriority(existing.source)) {
    381       byRoot.set(key, inst);
    382     }
    383   }
    384   const instances = Array.from(byRoot.values()).sort((a, b) => a.rootDir.localeCompare(b.rootDir));
    385   printDiscovered(instances);
    386 
    387   if (!update) {
    388     console.log("");
    389     console.log("Tip: run with --update to pull/rebuild all detected instances.");
    390     return;
    391   }
    392   if (!instances.length) return;
    393 
    394   const summary = [];
    395   for (const inst of instances) {
    396     const rel = displayPath(inst.rootDir);
    397     log(`Updating ${rel}`);
    398     let ok = true;
    399     let notes = [];
    400     try {
    401       if (!skipGit) {
    402         const inGit = run("git", ["-C", inst.rootDir, "rev-parse", "--is-inside-work-tree"], inst.rootDir, {
    403           dryRun,
    404           allowFail: true
    405         });
    406         if (inGit !== 0) {
    407           notes.push("not a git checkout (skipped git pull)");
    408         } else {
    409           run("git", ["-C", inst.rootDir, "fetch", remote], inst.rootDir, { dryRun });
    410           run("git", ["-C", inst.rootDir, "checkout", branch], inst.rootDir, { dryRun });
    411           run("git", ["-C", inst.rootDir, "pull", "--ff-only", remote, branch], inst.rootDir, { dryRun });
    412         }
    413       }
    414       const composeArgs = ["compose", "-f", inst.composeFile.replace(/\\/g, "/"), "up", "-d", "--remove-orphans"];
    415       if (!skipBuild) composeArgs.push("--build");
    416       run("docker", composeArgs, inst.rootDir, { dryRun });
    417       summary.push({ instance: rel, ok: true, notes: notes.join("; ") });
    418     } catch (e) {
    419       ok = false;
    420       notes.push(e?.message || String(e));
    421       summary.push({ instance: rel, ok, notes: notes.join("; ") });
    422       warn(`Failed update for ${rel}: ${notes.join("; ")}`);
    423     }
    424   }
    425 
    426   console.log("");
    427   console.log("Update summary:");
    428   for (const row of summary) {
    429     const status = row.ok ? "OK" : "FAIL";
    430     console.log(`- [${status}] ${row.instance}${row.notes ? ` - ${row.notes}` : ""}`);
    431   }
    432 }
    433 
    434 main();