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();