From 19db5fda822f1e4864b69cf6efcf1f12cb9d2aab Mon Sep 17 00:00:00 2001 From: zanjonke Date: Wed, 30 Sep 2026 15:38:54 +0200 Subject: [PATCH] Install plain-parser on minimal Linux images On bare Docker images (node:22-slim, ubuntu, curl without CA certificates) the uv install failed because astral's install.sh needs curl or wget and the system CA bundle. Download uv with Node's fetch instead, verify it against the published SHA-256, and fall back to install.sh. When plain-parser lands off PATH, print the exact export line. The healthcheck and env-check skills now find a plain-parser in ~/.local/bin before failing, and check-plain-env no longer fails on a missing system python3, since uv fetches its own. CI runs the real install inside node:22-slim, ubuntu:24.04 and alpine. --- .github/workflows/ci.yml | 33 ++++++++++++ README.md | 5 +- bin/download-uv.mjs | 56 +++++++++++++++++++ bin/python-tools.mjs | 62 +++++++++++++++++---- forge/skills/check-plain-env/SKILL.md | 2 +- forge/skills/plain-healthcheck/SKILL.md | 2 +- package.json | 1 + test/python-tools.test.mjs | 72 +++++++++++++++++++++---- 8 files changed, 209 insertions(+), 24 deletions(-) create mode 100644 bin/download-uv.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29c43ac..a7dddf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,36 @@ jobs: plain-parser check app.plain sed 's/:Implementation: prints/:Undefined: prints/' app.plain > bad.plain if plain-parser check bad.plain; then echo "bad.plain should fail"; exit 1; fi + + # Minimal images lack curl, wget, CA certificates and Python — the hosts where + # the uv install used to fail. Run the real install inside them. + install-plain-parser-minimal: + name: Install plain-parser (${{ matrix.image }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - image: node:22-slim + setup: "true" + - image: ubuntu:24.04 + setup: apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs + - image: alpine:3.20 + setup: apk add -q nodejs + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: plain-forge install + plain-parser check + run: | + docker run --rm -v "$GITHUB_WORKSPACE:/src:ro" "${{ matrix.image }}" sh -euc ' + ${{ matrix.setup }} + mkdir /project && cd /project + node /src/bin/cli.mjs install --agent claude --scope project | tee install.log + grep -E "plain-parser: installed" install.log + export PATH="$HOME/.local/bin:$PATH" + printf "%s\n" "***implementation reqs***" "" "- :Implementation: should be in Python." "" \ + "***functional specs***" "" "- :Implementation: prints hello." > app.plain + plain-parser check app.plain + ' diff --git a/README.md b/README.md index b80f6ff..2fabb41 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ This prompts you to pick an agent and a scope using an arrow-key menu. You can a npx plain-forge install --agent claude --scope project ``` -`install` also installs [`plain-parser`](https://pypi.org/project/plain-parser/), the Python CLI that `plain-healthcheck` uses to validate `.plain` files. It installs it with [uv](https://docs.astral.sh/uv/), installing uv first if it is missing, on Linux, macOS and Windows alike. If plain-parser is already on your `PATH`, nothing is installed. Pass `--skip-plain-parser` to leave both alone. +`install` also installs [`plain-parser`](https://pypi.org/project/plain-parser/), the Python CLI that `plain-healthcheck` uses to validate `.plain` files. It installs it with [uv](https://docs.astral.sh/uv/), installing uv first if it is missing, on Linux, macOS and Windows alike. On Linux and macOS uv is downloaded by Node itself, so it also works in minimal Docker images without curl, wget or CA certificates; uv then fetches a Python for plain-parser if the host has none. uv puts plain-parser in `~/.local/bin`, which may not be on your `PATH` yet — `install` prints the `export PATH=…` line to add it. If plain-parser is already on your `PATH`, nothing is installed. Pass `--skip-plain-parser` to leave both alone. **Agent options:** @@ -218,6 +218,7 @@ forge/ # canonical content, copied verbatim on install bin/ cli.mjs # the `plain-forge` CLI — `install` and `update` commands python-tools.mjs # installs uv + plain-parser during `install` (until plain-parser ships on npm) + download-uv.mjs # downloads uv with Node's fetch, for hosts without curl/wget or CA certificates vendor/ pyro/ # git submodule: plainlang/pyro, pinned to the release plain-forge ships @@ -230,7 +231,7 @@ test/ cli.test.mjs # tests for the install / update CLI python-tools.test.mjs # tests for the uv + plain-parser install step -package.json # ships only `bin/cli.mjs`, `bin/python-tools.mjs` and `forge/` to npm +package.json # ships only `bin/cli.mjs`, `bin/python-tools.mjs`, `bin/download-uv.mjs` and `forge/` to npm ``` ### Bundled pyro skill diff --git a/bin/download-uv.mjs b/bin/download-uv.mjs new file mode 100644 index 0000000..f9ea8e8 --- /dev/null +++ b/bin/download-uv.mjs @@ -0,0 +1,56 @@ +// Downloads a uv release archive with Node's fetch, checks it against the +// published SHA-256, and puts `uv` and `uvx` into a bin directory. +// +// node download-uv.mjs +// +// Unlike astral's install.sh this needs neither curl/wget nor the system CA +// certificates, which minimal Docker images lack. Only `tar` is required. +// +// Temporary, like python-tools.mjs, which runs it: both go away once +// plain-parser ships as an npm package. +import { spawnSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +async function get(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +async function main(url, binDir) { + const [archive, checksum] = await Promise.all([get(url), get(`${url}.sha256`)]); + const expected = checksum.toString("utf8").trim().split(/\s+/)[0]; + const actual = crypto.createHash("sha256").update(archive).digest("hex"); + if (expected !== actual) throw new Error(`checksum mismatch for ${url}`); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "plain-forge-uv-")); + try { + fs.writeFileSync(path.join(tmp, "uv.tar.gz"), archive); + const tar = spawnSync("tar", ["-xzf", "uv.tar.gz"], { cwd: tmp, stdio: "inherit" }); + if (tar.error || tar.status !== 0) { + throw new Error(`could not unpack the archive: ${tar.error?.message ?? "tar failed"}`); + } + // The archive holds a single folder named after it: uv-/{uv,uvx}. + const srcDir = path.join(tmp, path.basename(url, ".tar.gz")); + fs.mkdirSync(binDir, { recursive: true }); + for (const exe of ["uv", "uvx"]) { + const dest = path.join(binDir, exe); + fs.copyFileSync(path.join(srcDir, exe), dest); + fs.chmodSync(dest, 0o755); + } + console.log(`installed uv and uvx into ${binDir}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +const [url, binDir] = process.argv.slice(2); +try { + await main(url, binDir); +} catch (err) { + console.error(`downloading uv failed: ${err.message}`); + process.exit(1); +} diff --git a/bin/python-tools.mjs b/bin/python-tools.mjs index 8323fee..62f9bb7 100644 --- a/bin/python-tools.mjs +++ b/bin/python-tools.mjs @@ -10,9 +10,21 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; const PLAIN_PARSER = "plain-parser"; +const DOWNLOAD_UV = path.join(path.dirname(fileURLToPath(import.meta.url)), "download-uv.mjs"); +const UV_RELEASES = "https://github.com/astral-sh/uv/releases/latest/download"; +// uv release targets per `${platform}-${arch}`. The Linux builds are static +// musl binaries, so they run on glibc and musl (Alpine) hosts alike. +const UV_TARGETS = { + "linux-x64": "x86_64-unknown-linux-musl", + "linux-arm64": "aarch64-unknown-linux-musl", + "darwin-x64": "x86_64-apple-darwin", + "darwin-arm64": "aarch64-apple-darwin", +}; + const UV_INSTALLERS = { win32: { cmd: "powershell.exe", @@ -39,8 +51,27 @@ const UV_INSTALLERS = { }, }; -function uvInstaller(platform) { - return platform === "win32" ? UV_INSTALLERS.win32 : UV_INSTALLERS.posix; +function uvManual(platform) { + return platform === "win32" ? UV_INSTALLERS.win32.manual : UV_INSTALLERS.posix.manual; +} + +// Where the uv installer puts uv, unless told otherwise. +function uvInstallDir(env, home) { + return env.UV_INSTALL_DIR || env.XDG_BIN_HOME || path.join(home, ".local", "bin"); +} + +// The ways to install uv, tried in order. On Linux and macOS, uv is first +// downloaded with Node itself (download-uv.mjs), which needs neither curl/wget +// nor system CA certificates; astral's install.sh is the fallback for +// platforms without a known release target, or when that download fails. +function uvInstallers(platform, arch, env, home) { + if (platform === "win32") return [UV_INSTALLERS.win32]; + const target = UV_TARGETS[`${platform}-${arch}`]; + const download = target && { + cmd: process.execPath, + args: [DOWNLOAD_UV, `${UV_RELEASES}/uv-${target}.tar.gz`, uvInstallDir(env, home)], + }; + return [download, UV_INSTALLERS.posix].filter(Boolean); } // Run a command. `inherit` streams its output to the user; otherwise stdout is @@ -76,16 +107,22 @@ function findUv({ platform, env, home, run, exists }) { return uvCandidates(platform, env, home).find(exists) ?? null; } -function warnOffPath(log, binDir) { +function warnOffPath(log, binDir, platform) { log(` plain-parser: installed${binDir ? ` into ${binDir}` : ""}, which is not on your PATH yet.`); - log(` run "uv tool update-shell" and open a new terminal.`); + if (binDir && platform !== "win32") { + log(` add it to PATH for this shell with:`); + log(` export PATH="${binDir}:$PATH"`); + log(` and run "uv tool update-shell" to make that permanent for new terminals.`); + } else { + log(` run "uv tool update-shell" and open a new terminal.`); + } return { status: "not-on-path" }; } function warnManual(log, reason, platform) { log(` plain-parser: not installed — ${reason}`); log(` plain-forge's healthcheck needs it. Install it yourself with:`); - log(` ${uvInstaller(platform).manual}`); + log(` ${uvManual(platform)}`); log(` uv tool install ${PLAIN_PARSER}`); } @@ -93,6 +130,7 @@ function warnManual(log, reason, platform) { // Returns { status } — "present", "installed", "not-on-path", or "failed". function ensurePlainParser({ platform = process.platform, + arch = process.arch, env = process.env, home = os.homedir(), run = runCommand, @@ -107,9 +145,11 @@ function ensurePlainParser({ let uv = findUv({ platform, env, home, run, exists }); if (!uv) { log(` uv: not found — installing it (https://docs.astral.sh/uv/)`); - const { cmd, args } = uvInstaller(platform); - const r = run(cmd, args, { inherit: true }); - uv = !r.error && r.status === 0 ? findUv({ platform, env, home, run, exists }) : null; + for (const { cmd, args } of uvInstallers(platform, arch, env, home)) { + const r = run(cmd, args, { inherit: true }); + uv = !r.error && r.status === 0 ? findUv({ platform, env, home, run, exists }) : null; + if (uv) break; + } if (!uv) { warnManual(log, "installing uv failed", platform); return { status: "failed" }; @@ -119,7 +159,7 @@ function ensurePlainParser({ // Installed by an earlier run, but its bin dir is not on PATH. const binDir = run(uv, ["tool", "dir", "--bin"]).stdout.trim(); const exe = platform === "win32" ? `${PLAIN_PARSER}.exe` : PLAIN_PARSER; - if (binDir && exists(path.join(binDir, exe))) return warnOffPath(log, binDir); + if (binDir && exists(path.join(binDir, exe))) return warnOffPath(log, binDir, platform); log(` plain-parser: not found — installing it with uv`); // uv downloads a suitable Python (3.11+) itself when the host has none. @@ -133,7 +173,7 @@ function ensurePlainParser({ log(` plain-parser: installed`); return { status: "installed" }; } - return warnOffPath(log, binDir); + return warnOffPath(log, binDir, platform); } -export { ensurePlainParser, uvInstaller, uvCandidates }; +export { ensurePlainParser, uvInstallers, uvCandidates }; diff --git a/forge/skills/check-plain-env/SKILL.md b/forge/skills/check-plain-env/SKILL.md index a12f725..a84d6a3 100644 --- a/forge/skills/check-plain-env/SKILL.md +++ b/forge/skills/check-plain-env/SKILL.md @@ -149,7 +149,7 @@ Add these to every requirement list regardless of project: - A shell matching the testing scripts' extension (Bash for `.sh`, PowerShell for `.ps1`). - `git` (the renderer uses it; almost every plain project tracks itself in git). -- `plain-parser` and **Python 3.11 or newer** — `plain-healthcheck` validates every module with `plain-parser check`. Probe `python3 --version` and `plain-parser --help`; install with `pipx install plain-parser` or `uv tool install plain-parser`. +- `plain-parser` and **Python 3.11 or newer** — `plain-healthcheck` validates every module with `plain-parser check`. Probe `python3 --version` and `plain-parser --help`; install with `pipx install plain-parser` or `uv tool install plain-parser`. When `plain-parser --help` is not found but `~/.local/bin/plain-parser` (or the same file in `uv tool dir --bin`) exists, report `WARN`, not `FAIL`: it is installed but off `PATH`, fixed with `export PATH="$HOME/.local/bin:$PATH"` or `uv tool update-shell`. `plain-parser` is installed with uv, which fetches its own Python, so do not `FAIL` on a missing or older system `python3` when `plain-parser` itself runs. ### Step 3 — Probe the host diff --git a/forge/skills/plain-healthcheck/SKILL.md b/forge/skills/plain-healthcheck/SKILL.md index 4fb3925..7336c16 100644 --- a/forge/skills/plain-healthcheck/SKILL.md +++ b/forge/skills/plain-healthcheck/SKILL.md @@ -76,7 +76,7 @@ Step 3 has two halves: a mechanical parse by `plain-parser`, then the checks tha [`plain-parser`](https://pypi.org/project/plain-parser/) is the ***plain parser. It parses a module together with its whole `import` / `requires` chain, so running it on every top module covers every module in the project. -1. **Check it is installed.** Run `plain-parser --help`. If the command is not found, stop and report `FAIL` with the install command (`pipx install plain-parser` or `uv tool install plain-parser`; it needs Python 3.11 or newer). Do **not** fall back to checking these rules by reading the files — without the parser there is no syntax gate. +1. **Check it is installed.** Run `plain-parser --help`. If the command is not found, look for it where `plain-forge install` puts it before giving up: `~/.local/bin/plain-parser` (`%USERPROFILE%\.local\bin\plain-parser.exe` on Windows), or the directory printed by `uv tool dir --bin`. If it is there, run it by that full path for the rest of this step and tell the user to add the directory to `PATH`. If it is nowhere, stop and report `FAIL` with the install command (`pipx install plain-parser` or `uv tool install plain-parser`; it needs Python 3.11 or newer). Do **not** fall back to checking these rules by reading the files — without the parser there is no syntax gate. 2. **Run it from the top module's own directory**, once per top module: ```bash diff --git a/package.json b/package.json index 28c87db..17c33d6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "files": [ "bin/cli.mjs", "bin/python-tools.mjs", + "bin/download-uv.mjs", "forge" ], "scripts": { diff --git a/test/python-tools.test.mjs b/test/python-tools.test.mjs index 3696f51..0b922b4 100644 --- a/test/python-tools.test.mjs +++ b/test/python-tools.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import path from "node:path"; import { describe, test } from "node:test"; -import { ensurePlainParser, uvCandidates, uvInstaller } from "../bin/python-tools.mjs"; +import { ensurePlainParser, uvCandidates, uvInstallers } from "../bin/python-tools.mjs"; const HOME = path.join(path.sep, "home", "user"); const ENOENT = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); @@ -24,6 +24,7 @@ function fakeHost({ onPath = [], files = [], handlers = {} } = {}) { }; host.opts = (extra = {}) => ({ platform: "linux", + arch: "x64", env: {}, home: HOME, run: host.run, @@ -62,21 +63,49 @@ describe("ensurePlainParser", () => { assert.deepEqual(installs(host), [`${localUv()} tool install plain-parser`]); }); - test("installs uv with the shell installer on Linux, then plain-parser", () => { + test("downloads uv with Node on Linux, then installs plain-parser", () => { const host = fakeHost({ handlers: { - "sh -c": (h) => (h.files.add(localUv()), {}), + [process.execPath]: (h) => (h.files.add(localUv()), {}), [`${localUv()} tool install`]: (h) => (h.onPath.add("plain-parser"), {}), }, }); assert.deepEqual(ensurePlainParser(host.opts()), { status: "installed" }); const [uvInstall, ppInstall] = installs(host); + assert.match(uvInstall, /download-uv\.mjs /); + assert.match(uvInstall, /releases\/latest\/download\/uv-x86_64-unknown-linux-musl\.tar\.gz /); + assert.ok(uvInstall.endsWith(` ${path.join(HOME, ".local", "bin")}`), uvInstall); + assert.equal(ppInstall, `${localUv()} tool install plain-parser`); + }); + + test("falls back to the shell installer when the Node download fails", () => { + const host = fakeHost({ + handlers: { + [process.execPath]: () => ({ status: 1 }), + "sh -c": (h) => (h.files.add(localUv()), {}), + [`${localUv()} tool install`]: (h) => (h.onPath.add("plain-parser"), {}), + }, + }); + assert.deepEqual(ensurePlainParser(host.opts()), { status: "installed" }); + const [download, uvInstall, ppInstall] = installs(host); + assert.match(download, /download-uv\.mjs/); assert.match(uvInstall, /^sh -c .*curl -LsSf https:\/\/astral\.sh\/uv\/install\.sh \| sh/); assert.match(uvInstall, /wget -qO- https:\/\/astral\.sh\/uv\/install\.sh \| sh/); assert.match(uvInstall, /needs curl or wget/); assert.equal(ppInstall, `${localUv()} tool install plain-parser`); }); + test("uses only the shell installer on a platform with no known uv target", () => { + const host = fakeHost({ + handlers: { + "sh -c": (h) => (h.files.add(localUv()), {}), + [`${localUv()} tool install`]: (h) => (h.onPath.add("plain-parser"), {}), + }, + }); + assert.deepEqual(ensurePlainParser(host.opts({ arch: "s390x" })), { status: "installed" }); + assert.match(installs(host)[0], /^sh -c /); + }); + test("installs uv with the PowerShell installer on Windows", () => { const uvExe = localUv("uv.exe"); const host = fakeHost({ @@ -93,9 +122,11 @@ describe("ensurePlainParser", () => { }); test("warns with the manual commands when installing uv fails", () => { - const host = fakeHost({ handlers: { "sh -c": () => ({ status: 1 }) } }); + const host = fakeHost({ + handlers: { [process.execPath]: () => ({ status: 1 }), "sh -c": () => ({ status: 1 }) }, + }); assert.deepEqual(ensurePlainParser(host.opts()), { status: "failed" }); - assert.equal(installs(host).length, 1, "does not try uv tool install"); + assert.equal(installs(host).length, 2, "tries both uv installers, not uv tool install"); const out = host.logs.join("\n"); assert.match(out, /installing uv failed/); assert.match(out, /curl -LsSf https:\/\/astral\.sh\/uv\/install\.sh \| sh/); @@ -118,6 +149,18 @@ describe("ensurePlainParser", () => { assert.deepEqual(ensurePlainParser(host.opts()), { status: "not-on-path" }); const out = host.logs.join("\n"); assert.match(out, /installed into \/home\/user\/\.local\/bin, which is not on your PATH/); + assert.match(out, /export PATH="\/home\/user\/\.local\/bin:\$PATH"/); + assert.match(out, /uv tool update-shell/); + }); + + test("does not print an export line on Windows", () => { + const host = fakeHost({ + onPath: ["uv"], + handlers: { "uv tool dir --bin": () => ({ stdout: "C:\\Users\\user\\.local\\bin\n" }) }, + }); + assert.deepEqual(ensurePlainParser(host.opts({ platform: "win32" })), { status: "not-on-path" }); + const out = host.logs.join("\n"); + assert.doesNotMatch(out, /export PATH/); assert.match(out, /uv tool update-shell/); }); @@ -135,10 +178,21 @@ describe("ensurePlainParser", () => { }); describe("uv helpers", () => { - test("uvInstaller picks PowerShell on Windows and sh elsewhere", () => { - assert.equal(uvInstaller("win32").cmd, "powershell.exe"); - assert.equal(uvInstaller("linux").cmd, "sh"); - assert.equal(uvInstaller("darwin").cmd, "sh"); + test("uvInstallers tries the Node download before install.sh, and PowerShell on Windows", () => { + const cmds = (platform, arch) => uvInstallers(platform, arch, {}, HOME).map((i) => i.cmd); + assert.deepEqual(cmds("win32", "x64"), ["powershell.exe"]); + assert.deepEqual(cmds("linux", "arm64"), [process.execPath, "sh"]); + assert.deepEqual(cmds("darwin", "arm64"), [process.execPath, "sh"]); + assert.deepEqual(cmds("linux", "s390x"), ["sh"]); + }); + + test("uvInstallers picks the release archive and install dir for the host", () => { + const [linux] = uvInstallers("linux", "arm64", {}, HOME); + assert.match(linux.args[1], /\/uv-aarch64-unknown-linux-musl\.tar\.gz$/); + assert.equal(linux.args[2], path.join(HOME, ".local", "bin")); + const [mac] = uvInstallers("darwin", "x64", { UV_INSTALL_DIR: "/opt/uv" }, HOME); + assert.match(mac.args[1], /\/uv-x86_64-apple-darwin\.tar\.gz$/); + assert.equal(mac.args[2], "/opt/uv"); }); test("uvCandidates honors UV_INSTALL_DIR and XDG_BIN_HOME first", () => {