Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
'
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions bin/download-uv.mjs
Original file line number Diff line number Diff line change
@@ -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 <archive-url> <bin-dir>
//
// 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-<target>/{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);
}
62 changes: 51 additions & 11 deletions bin/python-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -76,23 +107,30 @@ 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}`);
}

// Make sure `plain-parser` is installed, installing uv first if needed.
// 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,
Expand All @@ -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" };
Expand All @@ -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.
Expand All @@ -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 };
2 changes: 1 addition & 1 deletion forge/skills/check-plain-env/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion forge/skills/plain-healthcheck/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"files": [
"bin/cli.mjs",
"bin/python-tools.mjs",
"bin/download-uv.mjs",
"forge"
],
"scripts": {
Expand Down
72 changes: 63 additions & 9 deletions test/python-tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -24,6 +24,7 @@ function fakeHost({ onPath = [], files = [], handlers = {} } = {}) {
};
host.opts = (extra = {}) => ({
platform: "linux",
arch: "x64",
env: {},
home: HOME,
run: host.run,
Expand Down Expand Up @@ -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({
Expand All @@ -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/);
Expand All @@ -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/);
});

Expand All @@ -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", () => {
Expand Down
Loading