From 4b5f3201726ff7c7cbf9e527253b2180bf125ccd Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Sun, 18 Oct 2026 12:13:37 -0500 Subject: [PATCH 1/2] test: check that each vland template's mise.lock locks its mise.toml The generated CI runs `mise install --locked`, so a template whose mise.toml drifts from its mise.lock breaks every project made from it. Check that the lock covers every [tools] request on every platform it locks (and linux-x64), that init ships it byte for byte, that `mise install --locked --dry-run` passes on the generated project with mise isolated from the machine, and that init needs no mise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EZVC7xzzsEmByszmfff4u2 --- vland/cli/test/helpers.ts | 35 +++- .../test/integration/init-mise-lock.test.ts | 175 ++++++++++++++++++ .../test/integration/init-templates.test.ts | 2 +- 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 vland/cli/test/integration/init-mise-lock.test.ts diff --git a/vland/cli/test/helpers.ts b/vland/cli/test/helpers.ts index 2cd51b8..bc57ea2 100644 --- a/vland/cli/test/helpers.ts +++ b/vland/cli/test/helpers.ts @@ -6,7 +6,7 @@ import path from "node:path"; export { existsSync as pathExists }; const REPO_ROOT = path.resolve(import.meta.dirname, "..", "..", ".."); -const TEMPLATES_DIR = path.join(REPO_ROOT, "vland", "templates"); +export const TEMPLATES_DIR = path.join(REPO_ROOT, "vland", "templates"); const VLAND_BIN = path.join(REPO_ROOT, "vland", "cli", "bin"); type CliMode = "dev" | "prod"; @@ -75,3 +75,36 @@ export function biomeCheck(cwd: string) { env: { ...process.env, NO_COLOR: "1" }, }); } + +/** + * Runs `mise install --locked --dry-run` in the project in `cwd`: the install + * the generated CI's mise-action runs when the project has a mise.lock. mise + * runs offline, with empty data, cache and state dirs, and without the + * machine's global config, system config, MISE_ENV or any config above `cwd`, + * so it reads only the project's mise.toml and mise.lock. + */ +export function miseLockedCheck(cwd: string) { + const home = mkdtempSync(path.join(tmpdir(), "vland-mise-")); + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^_*MISE_/.test(key))); + try { + return spawnSync("mise", ["install", "--locked", "--dry-run"], { + cwd, + encoding: "utf8", + env: { + ...env, + NO_COLOR: "1", + MISE_OFFLINE: "1", + MISE_DATA_DIR: path.join(home, "data"), + MISE_CACHE_DIR: path.join(home, "cache"), + MISE_STATE_DIR: path.join(home, "state"), + MISE_CONFIG_DIR: path.join(home, "config"), + MISE_GLOBAL_CONFIG_FILE: path.join(home, "config", "config.toml"), + MISE_SYSTEM_CONFIG_FILE: path.join(home, "system", "config.toml"), + MISE_CEILING_PATHS: path.dirname(cwd), + MISE_TRUSTED_CONFIG_PATHS: cwd, + }, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } +} diff --git a/vland/cli/test/integration/init-mise-lock.test.ts b/vland/cli/test/integration/init-mise-lock.test.ts new file mode 100644 index 0000000..06796ee --- /dev/null +++ b/vland/cli/test/integration/init-mise-lock.test.ts @@ -0,0 +1,175 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { TEMPLATES, type TemplateName } from "#src/actions/template.ts"; +import { createTestCli, makeTmpDir, miseLockedCheck, TEMPLATES_DIR } from "#test/helpers.ts"; + +const cli = createTestCli(); + +// The generated CI runs on ubuntu-latest, so mise.lock has to lock linux-x64. +const CI_PLATFORM = "linux-x64"; + +// A machine without mise skips the tests that run it. dx's CI sets mise up, so they run there. +const HAS_MISE = spawnSync("mise", ["--version"]).status === 0; + +type LockedVersion = { specifiers: string[]; urls: Set }; + +/** + * Reads the `[[tools.]]` entries of a mise.lock: the requests each one + * locks and the platforms it has a url for. `platforms` holds every platform + * the lock has a table for, plus CI's. + */ +function readLock(miseLock: string) { + const tools = new Map(); + const platforms = new Set([CI_PLATFORM]); + let locked: LockedVersion | undefined; + let platform: string | undefined; + + for (const line of miseLock.split("\n")) { + const entry = line.match(/^\[\[tools\.([\w-]+)\]\]$/); + const table = line.match(/^\[tools\.([\w-]+)\."platforms\.([\w-]+)"\]$/); + if (entry) { + const tool = entry[1] ?? ""; + locked = { specifiers: [], urls: new Set() }; + tools.set(tool, [...(tools.get(tool) ?? []), locked]); + platform = undefined; + } else if (table) { + // A platform table belongs to the last `[[tools.]]` entry above it. + locked = tools.get(table[1] ?? "")?.at(-1); + platform = table[2] ?? ""; + platforms.add(platform); + } else if (line.startsWith("[")) { + locked = undefined; + } else if (locked && !platform && line.startsWith("specifiers = ")) { + locked.specifiers = [...line.matchAll(/"([^"]*)"/g)].map((match) => match[1] ?? ""); + } else if (locked && platform && line.startsWith("url = ")) { + locked.urls.add(platform); + } + } + + return { tools, platforms }; +} + +/** + * Lists what `mise install --locked` would miss, on any platform the lock + * covers or CI's: each `[tools]` request of mise.toml that mise.lock doesn't + * lock, or locks without a url for one of those platforms. + */ +function lockGaps(miseToml: string, miseLock: string): string[] { + const { tools, platforms } = readLock(miseLock); + const gaps: string[] = []; + let section: string | undefined; + + for (const line of miseToml.split("\n")) { + const header = line.match(/^\[(.+)\]$/); + if (header) { + section = header[1]; + continue; + } + if (section !== "tools" || line.trim() === "" || line.startsWith("#")) continue; + + const request = line.match(/^([\w-]+) = "([^"]+)"$/); + if (!request) { + gaps.push(`mise.toml has a [tools] line this check can't read: ${line}`); + continue; + } + const tool = request[1] ?? ""; + const version = request[2] ?? ""; + const locked = tools.get(tool)?.find((entry) => entry.specifiers.includes(version)); + if (!locked) { + gaps.push(`${tool}@${version} is not in mise.lock`); + continue; + } + for (const missing of [...platforms].filter((name) => !locked.urls.has(name))) { + gaps.push(`${tool}@${version} has no url for ${missing} in mise.lock`); + } + } + + return gaps; +} + +function readTemplateFile(template: TemplateName, file: string): Promise { + return readFile(path.join(TEMPLATES_DIR, template, file), "utf8"); +} + +describe("vland init (mise lock)", () => { + let fixture: { dir: string; cleanup: () => void }; + + beforeEach(() => { + fixture = makeTmpDir("init-mise-lock"); + }); + + afterEach(() => fixture.cleanup()); + + test.each(TEMPLATES)("%s: ships the template's mise.lock, which locks every mise.toml tool", async (template) => { + const name = `lock-${template}`; + const r = cli(`init ${name} -t ${template} --no-install --no-git`, { cwd: fixture.dir }); + expect(r.status).toBe(0); + + const projectDir = path.join(fixture.dir, name); + const miseLock = await readFile(path.join(projectDir, "mise.lock"), "utf8"); + expect(miseLock).toBe(await readTemplateFile(template, "mise.lock")); + + // After a change to a template's mise.toml, `mise lock` in that template's directory fixes this. + const miseToml = await readFile(path.join(projectDir, "mise.toml"), "utf8"); + expect(lockGaps(miseToml, miseLock)).toEqual([]); + }); + + test("the lock check reports a mise.toml request that mise.lock doesn't lock", async () => { + const miseToml = await readTemplateFile("library", "mise.toml"); + const miseLock = await readTemplateFile("library", "mise.lock"); + + const drifted = miseToml.replace(/^pnpm = ".+"$/m, 'pnpm = "0.0.0"'); + expect(lockGaps(drifted, miseLock)).toEqual(["pnpm@0.0.0 is not in mise.lock"]); + }); + + test("the lock check reports a platform that a locked tool has no url for", async () => { + const miseToml = await readTemplateFile("library", "mise.toml"); + const miseLock = await readTemplateFile("library", "mise.lock"); + + const drifted = miseLock.replace(/(\[tools\.node\."platforms\.linux-x64"\]\n(?:.+\n)*?)url = .+\n/, "$1"); + expect(lockGaps(miseToml, drifted)).toEqual([expect.stringMatching(/^node@\S+ has no url for linux-x64 in mise.lock$/)]); + }); + + test("library: init needs no mise and still ships the template's mise.lock", async () => { + // A PATH with node and the system tools bin/ needs (bash, dirname, readlink), but no mise. + const bin = path.join(fixture.dir, "bin"); + await mkdir(bin); + await symlink(process.execPath, path.join(bin, "node")); + const PATH = [bin, "/usr/bin", "/bin"].join(path.delimiter); + expect(spawnSync("/bin/sh", ["-c", "command -v mise"], { env: { PATH } }).status).not.toBe(0); + + const r = cli("init no-mise -t library --no-install --no-git", { cwd: fixture.dir, env: { PATH } }); + expect(r.status, r.stdout + r.stderr).toBe(0); + + const miseLock = await readFile(path.join(fixture.dir, "no-mise", "mise.lock"), "utf8"); + expect(miseLock).toBe(await readTemplateFile("library", "mise.lock")); + }); + + describe.runIf(HAS_MISE)("with mise", () => { + test.each(TEMPLATES)("%s: `mise install --locked` resolves every tool from the shipped mise.lock", (template) => { + const name = `locked-${template}`; + const r = cli(`init ${name} -t ${template} --no-install --no-git`, { cwd: fixture.dir }); + expect(r.status).toBe(0); + + const check = miseLockedCheck(path.join(fixture.dir, name)); + expect(check.status, check.stdout + check.stderr).toBe(0); + }); + + test("library: `mise install --locked` fails on a mise.toml request that mise.lock doesn't lock", async () => { + const name = "drifted"; + const r = cli(`init ${name} -t library --no-install --no-git`, { cwd: fixture.dir }); + expect(r.status).toBe(0); + + const projectDir = path.join(fixture.dir, name); + const miseTomlPath = path.join(projectDir, "mise.toml"); + const miseToml = await readFile(miseTomlPath, "utf8"); + await writeFile(miseTomlPath, miseToml.replace(/^pnpm = ".+"$/m, 'pnpm = "0.0.0"')); + + const check = miseLockedCheck(projectDir); + expect(check.status).toBe(1); + expect(check.stderr).toContain("pnpm@0.0.0 is not in the lockfile"); + }); + }); +}); diff --git a/vland/cli/test/integration/init-templates.test.ts b/vland/cli/test/integration/init-templates.test.ts index b655b7c..f3e9952 100644 --- a/vland/cli/test/integration/init-templates.test.ts +++ b/vland/cli/test/integration/init-templates.test.ts @@ -145,7 +145,7 @@ describe("vland init (template scaffolding)", () => { const miseToml = await readFile(path.join(projectDir, "mise.toml"), "utf8"); expect(miseToml).toMatch(/\[settings\]\nlockfile = true/); - // CI's mise-action runs `mise install --locked`, which needs every tool locked. + // mise-action runs `mise install --locked` in a project with a mise.lock. init-mise-lock.test.ts checks it in full. const miseLock = await readFile(path.join(projectDir, "mise.lock"), "utf8"); for (const tool of ["node", "pnpm"]) { expect(miseLock).toContain(`[[tools.${tool}]]`); From 2827a1d99d744f0bc760cd75dbb5bdb4424debcc Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Sun, 18 Oct 2026 12:13:37 -0500 Subject: [PATCH 2/2] docs: say to re-lock a vland template after changing its mise.toml Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EZVC7xzzsEmByszmfff4u2 --- docs/development.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/development.md b/docs/development.md index d36c852..96b4cca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -77,3 +77,5 @@ rr plugins add biome@pr-123 pnpm --filter ./run-run/cli build usage generate markdown --file run-run/cli/dist/cli.usage.kdl --out-file run-run/cli/CLI.md --replace-pre-with-code-fences ``` + +Each `vland/templates/*/mise.lock` is generated by `mise lock`. After changing a template's `mise.toml`, run `mise lock` in that template's directory and commit both files together: `vland init` fetches the templates from `main`, and the CI it generates runs `mise install --locked`, which fails on a tool the lock is missing.