Merge pull request 'lab: install each checkout where its lock file is, check out mesh-tools-go, and have lab_check catch both (issue 624)' (#268) from fix/624-lab-run-installs-what-each-checkout-has into main

This commit was merged in pull request #268.
This commit is contained in:
2026-10-13 11:11:58 +00:00
4 changed files with 194 additions and 7 deletions
+3
View File
@@ -4,6 +4,9 @@
"description": "lab — the lab, as a module: runs beds against the forge's branches when the mesh asks (novox/hq ADR 0172).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
+100
View File
@@ -0,0 +1,100 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts";
// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
// setup, in 9 seconds, before anything was built.
function aCheckout(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "lab-checkout-"));
for (const [path, content] of Object.entries(files)) {
mkdirSync(join(root, path, ".."), { recursive: true });
writeFileSync(join(root, path), content);
}
return root;
}
test("a checkout with a lock file at its root is installed there", () => {
const root = aCheckout({ "package.json": "{}", "package-lock.json": "{}", "node-tools/package.json": "{}" });
assert.deepEqual(installPlan(root), { install: [root], unlocked: [] });
rmSync(root, { recursive: true, force: true });
});
test("a checkout whose package lives one directory down is installed there, as mesh-tools' node-tools", () => {
const root = aCheckout({ "node-tools/package.json": "{}", "node-tools/package-lock.json": "{}", "Dockerfile": "" });
assert.deepEqual(installPlan(root), { install: [join(root, "node-tools")], unlocked: [] });
rmSync(root, { recursive: true, force: true });
});
test("a package.json with no lock file is named, never installed", () => {
const root = aCheckout({ "package.json": "{}" });
assert.deepEqual(installPlan(root), { install: [], unlocked: [root] });
rmSync(root, { recursive: true, force: true });
});
// The shell a run executes follows the same rule: npm is asked where installPlan says, and a checkout
// with nothing installable fails the run loudly, naming it.
function runStep(root: string, build: boolean): { ok: boolean; said: string; asked: string[] } {
const bin = mkdtempSync(join(tmpdir(), "lab-npm-"));
const record = join(bin, "asked");
writeFileSync(join(bin, "npm"), `#!/bin/sh\necho "$(pwd) $*" >> ${JSON.stringify(record)}\n`);
chmodSync(join(bin, "npm"), 0o755);
const ran = spawnSync("bash", ["-c", `set -euo pipefail\n${installStep(root, build)}`], {
encoding: "utf8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
});
let asked: string[] = [];
try {
asked = readFileSync(record, "utf8").trim().split("\n").filter(Boolean);
} catch { /* nothing asked */ }
rmSync(bin, { recursive: true, force: true });
return { ok: ran.status === 0, said: `${ran.stdout}${ran.stderr}`, asked };
}
test("a run installs and builds mesh-tools in node-tools, where its lock file is", () => {
const root = aCheckout({ "node-tools/package.json": "{}", "node-tools/package-lock.json": "{}" });
const r = runStep(root, true);
assert.ok(r.ok, r.said);
assert.deepEqual(r.asked, [
`${join(root, "node-tools")} ci --no-audit --no-fund --loglevel=error`,
`${join(root, "node-tools")} run build --if-present`,
]);
rmSync(root, { recursive: true, force: true });
});
test("a run installs a checkout with its lock file at the root there, and builds only when asked", () => {
const root = aCheckout({ "package.json": "{}", "package-lock.json": "{}" });
const r = runStep(root, false);
assert.ok(r.ok, r.said);
assert.deepEqual(r.asked, [`${root} ci --no-audit --no-fund --loglevel=error`]);
rmSync(root, { recursive: true, force: true });
});
test("a run fails at its setup, naming the checkout, when nothing of it can be installed", () => {
const layouts: Record<string, string>[] = [{ "package.json": "{}" }, { "Dockerfile": "" }, { "sub/package.json": "{}" }];
for (const files of layouts) {
const root = aCheckout(files);
const r = runStep(root, false);
assert.equal(r.ok, false, `installed ${JSON.stringify(files)}`);
assert.match(r.said, /issue 624/);
assert.deepEqual(r.asked, []);
rmSync(root, { recursive: true, force: true });
}
});
// A run checks out mesh-tools-go too (novox/hq issue 616): genesis builds the Go toolchain from the
// repository beside mesh-tools, and a bed that raises genesis serves it. And it tells the beds where the
// checkouts are.
test("a run checks out mesh-tools-go and says where its checkouts are", () => {
assert.ok(REPOSITORIES.includes("mesh-tools-go"));
const s = script("/work", "run-x", "http://forge", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
assert.match(s, /novox\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
assert.match(s, /export MESH_LAB_REPOS='\/work\/run-x'/);
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
});
+39 -2
View File
@@ -4,7 +4,7 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
import { installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
@@ -26,7 +26,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() };
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
// repository it clones answers on the forge, and every one it installs has a lock file where the run
// installs from.
const problems = setupProblems(forge, `${work}/check-setup`);
const output = [`${check.stdout}${check.stderr}`.trim(), ...problems].filter(Boolean).join("\n");
return { ok: check.status === 0 && problems.length === 0, output };
},
},
{
@@ -89,6 +94,38 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
];
}
/**
* What would stop a run at its setup, in sentences: a repository the run clones that the forge does not
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
* would go through.
*/
export function setupProblems(forge: string, dir: string): string[] {
const problems: string[] = [];
spawnSync("rm", ["-rf", dir]);
for (const repo of REPOSITORIES) {
const url = `${forge}/novox/${repo}.git`;
const remote = spawnSync("git", ["ls-remote", "--heads", url, "main"], { encoding: "utf8" });
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
problems.push(`a run checks out ${repo}, and ${url} answers no main branch`);
continue;
}
if (!NPM_REPOSITORIES.includes(repo)) continue;
const at = `${dir}/${repo}`;
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", url, at], { encoding: "utf8" });
if (clone.status !== 0) {
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
continue;
}
const plan = installPlan(at);
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
if (plan.install.length === 0 && plan.unlocked.length === 0) {
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
}
}
spawnSync("rm", ["-rf", dir]);
return problems;
}
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
export function wordIn(file: string | undefined, word: string): string {
if (!file) return "";
+52 -5
View File
@@ -10,8 +10,55 @@ import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** The repositories a lab run checks out, side by side, as the lab expects its siblings. */
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-sdk"];
/**
* The repositories a lab run checks out, side by side, as the lab expects its siblings. mesh-tools-go is
* one since genesis builds the Go toolchain at its base step, from the repository beside mesh-tools
* (novox/hq issue 616): a bed that raises genesis serves it from its forge.
*/
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-tools-go", "mesh-sdk"];
/** The checkouts whose JavaScript dependencies a run installs: the lab, the SDK and the base. */
export const NPM_REPOSITORIES = ["mesh-sdk", "mesh-tools", "mesh-lab"];
/**
* Where a checkout's JavaScript dependencies are installed (novox/hq issue 624): its root when the root
* holds a lock file, otherwise each directory one level down that holds one. `npm ci` installs only from a
* lock file, and a repository that moved its package into a directory of its own (mesh-tools' node-tools)
* has none at its root; the run asked for one there and failed before it built anything. A package.json
* with no lock file beside it cannot be installed reproducibly, and is named rather than installed.
*/
export function installPlan(root: string): { install: string[]; unlocked: string[] } {
const has = (dir: string, file: string) => existsSync(join(dir, file));
if (has(root, "package-lock.json")) return { install: [root], unlocked: [] };
const install: string[] = [];
const unlocked: string[] = has(root, "package.json") ? [root] : [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
const dir = join(root, entry.name);
if (has(dir, "package-lock.json")) install.push(dir);
else if (has(dir, "package.json")) unlocked.push(dir);
}
return { install: install.sort(), unlocked: unlocked.sort() };
}
/**
* installStep is the shell a run installs one checkout with: the same rule as installPlan, so what
* lab_check says a run would install is what it installs. Building runs where the package says how.
*/
export function installStep(root: string, build: boolean): string {
const q = shellQuote(root);
const install = `(cd "$d" && npm ci --no-audit --no-fund --loglevel=error${build ? " && npm run build --if-present" : ""})`;
return `if [ -f ${q}/package-lock.json ]; then d=${q}; ${install}; else
[ ! -f ${q}/package.json ] || { echo "${root}: a package.json and no package-lock.json, so npm ci cannot install it (novox/hq issue 624)" >&2; exit 1; }
found=0
for d in ${q}/*/; do
d="\${d%/}"; [ "$(basename "$d")" = node_modules ] && continue
if [ -f "$d/package-lock.json" ]; then found=1; ${install}
elif [ -f "$d/package.json" ]; then echo "$d: a package.json and no package-lock.json, so npm ci cannot install it (novox/hq issue 624)" >&2; exit 1; fi
done
[ "$found" = 1 ] || { echo "${root}: no package-lock.json at its root or one directory down, so nothing of it can be installed (novox/hq issue 624)" >&2; exit 1; }
fi`;
}
export interface RunStatus {
id: string;
@@ -104,9 +151,7 @@ ${setState("building")}
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done
(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present)
(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present)
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
mkdir -p ${shellQuote(bin)}
for p in postgres-provisioner objectstore-provisioner route-proxy; do
(cd ${shellQuote(dir)}/mesh-controller && CGO_ENABLED=0 go build -o ${shellQuote(bin)}/$p ./examples/$p)
@@ -117,6 +162,8 @@ export MESH_LAB_MODULES=${shellQuote(join(dir, "mesh-controller", "examples", "m
export MESH_LAB_BUILDER=${shellQuote(join(dir, "mesh-controller", "build", "mesh-builder"))}
export MESH_LAB_BOOTSTRAP_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-bootstrap"))}
export MESH_LAB_CATALOG=${shellQuote(join(dir, "mesh-catalog", "modules"))}
# Where a bed that serves repositories from its own forge finds them: this run's checkouts, side by side.
export MESH_LAB_REPOS=${shellQuote(dir)}
export MESH_LAB_PROVISIONER=${shellQuote(join(bin, "postgres-provisioner"))}
export MESH_LAB_OBJECTSTORE_PROVISIONER=${shellQuote(join(bin, "objectstore-provisioner"))}
export MESH_LAB_ROUTE_PROXY=${shellQuote(join(bin, "route-proxy"))}