plain-forge 1.0.22 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ This prompts you to pick an agent and a scope using an arrow-key menu. You can a
56
56
  npx plain-forge install --agent claude --scope project
57
57
  ```
58
58
 
59
- `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.
59
+ `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.
60
60
 
61
61
  **Agent options:**
62
62
 
@@ -218,6 +218,7 @@ forge/ # canonical content, copied verbatim on install
218
218
  bin/
219
219
  cli.mjs # the `plain-forge` CLI — `install` and `update` commands
220
220
  python-tools.mjs # installs uv + plain-parser during `install` (until plain-parser ships on npm)
221
+ download-uv.mjs # downloads uv with Node's fetch, for hosts without curl/wget or CA certificates
221
222
 
222
223
  vendor/
223
224
  pyro/ # git submodule: plainlang/pyro, pinned to the release plain-forge ships
@@ -230,7 +231,7 @@ test/
230
231
  cli.test.mjs # tests for the install / update CLI
231
232
  python-tools.test.mjs # tests for the uv + plain-parser install step
232
233
 
233
- package.json # ships only `bin/cli.mjs`, `bin/python-tools.mjs` and `forge/` to npm
234
+ package.json # ships only `bin/cli.mjs`, `bin/python-tools.mjs`, `bin/download-uv.mjs` and `forge/` to npm
234
235
  ```
235
236
 
236
237
  ### Bundled pyro skill
@@ -0,0 +1,56 @@
1
+ // Downloads a uv release archive with Node's fetch, checks it against the
2
+ // published SHA-256, and puts `uv` and `uvx` into a bin directory.
3
+ //
4
+ // node download-uv.mjs <archive-url> <bin-dir>
5
+ //
6
+ // Unlike astral's install.sh this needs neither curl/wget nor the system CA
7
+ // certificates, which minimal Docker images lack. Only `tar` is required.
8
+ //
9
+ // Temporary, like python-tools.mjs, which runs it: both go away once
10
+ // plain-parser ships as an npm package.
11
+ import { spawnSync } from "node:child_process";
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+
17
+ async function get(url) {
18
+ const res = await fetch(url);
19
+ if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
20
+ return Buffer.from(await res.arrayBuffer());
21
+ }
22
+
23
+ async function main(url, binDir) {
24
+ const [archive, checksum] = await Promise.all([get(url), get(`${url}.sha256`)]);
25
+ const expected = checksum.toString("utf8").trim().split(/\s+/)[0];
26
+ const actual = crypto.createHash("sha256").update(archive).digest("hex");
27
+ if (expected !== actual) throw new Error(`checksum mismatch for ${url}`);
28
+
29
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "plain-forge-uv-"));
30
+ try {
31
+ fs.writeFileSync(path.join(tmp, "uv.tar.gz"), archive);
32
+ const tar = spawnSync("tar", ["-xzf", "uv.tar.gz"], { cwd: tmp, stdio: "inherit" });
33
+ if (tar.error || tar.status !== 0) {
34
+ throw new Error(`could not unpack the archive: ${tar.error?.message ?? "tar failed"}`);
35
+ }
36
+ // The archive holds a single folder named after it: uv-<target>/{uv,uvx}.
37
+ const srcDir = path.join(tmp, path.basename(url, ".tar.gz"));
38
+ fs.mkdirSync(binDir, { recursive: true });
39
+ for (const exe of ["uv", "uvx"]) {
40
+ const dest = path.join(binDir, exe);
41
+ fs.copyFileSync(path.join(srcDir, exe), dest);
42
+ fs.chmodSync(dest, 0o755);
43
+ }
44
+ console.log(`installed uv and uvx into ${binDir}`);
45
+ } finally {
46
+ fs.rmSync(tmp, { recursive: true, force: true });
47
+ }
48
+ }
49
+
50
+ const [url, binDir] = process.argv.slice(2);
51
+ try {
52
+ await main(url, binDir);
53
+ } catch (err) {
54
+ console.error(`downloading uv failed: ${err.message}`);
55
+ process.exit(1);
56
+ }
@@ -10,9 +10,21 @@ import { spawnSync } from "node:child_process";
10
10
  import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
13
14
 
14
15
  const PLAIN_PARSER = "plain-parser";
15
16
 
17
+ const DOWNLOAD_UV = path.join(path.dirname(fileURLToPath(import.meta.url)), "download-uv.mjs");
18
+ const UV_RELEASES = "https://github.com/astral-sh/uv/releases/latest/download";
19
+ // uv release targets per `${platform}-${arch}`. The Linux builds are static
20
+ // musl binaries, so they run on glibc and musl (Alpine) hosts alike.
21
+ const UV_TARGETS = {
22
+ "linux-x64": "x86_64-unknown-linux-musl",
23
+ "linux-arm64": "aarch64-unknown-linux-musl",
24
+ "darwin-x64": "x86_64-apple-darwin",
25
+ "darwin-arm64": "aarch64-apple-darwin",
26
+ };
27
+
16
28
  const UV_INSTALLERS = {
17
29
  win32: {
18
30
  cmd: "powershell.exe",
@@ -39,8 +51,27 @@ const UV_INSTALLERS = {
39
51
  },
40
52
  };
41
53
 
42
- function uvInstaller(platform) {
43
- return platform === "win32" ? UV_INSTALLERS.win32 : UV_INSTALLERS.posix;
54
+ function uvManual(platform) {
55
+ return platform === "win32" ? UV_INSTALLERS.win32.manual : UV_INSTALLERS.posix.manual;
56
+ }
57
+
58
+ // Where the uv installer puts uv, unless told otherwise.
59
+ function uvInstallDir(env, home) {
60
+ return env.UV_INSTALL_DIR || env.XDG_BIN_HOME || path.join(home, ".local", "bin");
61
+ }
62
+
63
+ // The ways to install uv, tried in order. On Linux and macOS, uv is first
64
+ // downloaded with Node itself (download-uv.mjs), which needs neither curl/wget
65
+ // nor system CA certificates; astral's install.sh is the fallback for
66
+ // platforms without a known release target, or when that download fails.
67
+ function uvInstallers(platform, arch, env, home) {
68
+ if (platform === "win32") return [UV_INSTALLERS.win32];
69
+ const target = UV_TARGETS[`${platform}-${arch}`];
70
+ const download = target && {
71
+ cmd: process.execPath,
72
+ args: [DOWNLOAD_UV, `${UV_RELEASES}/uv-${target}.tar.gz`, uvInstallDir(env, home)],
73
+ };
74
+ return [download, UV_INSTALLERS.posix].filter(Boolean);
44
75
  }
45
76
 
46
77
  // Run a command. `inherit` streams its output to the user; otherwise stdout is
@@ -76,16 +107,22 @@ function findUv({ platform, env, home, run, exists }) {
76
107
  return uvCandidates(platform, env, home).find(exists) ?? null;
77
108
  }
78
109
 
79
- function warnOffPath(log, binDir) {
110
+ function warnOffPath(log, binDir, platform) {
80
111
  log(` plain-parser: installed${binDir ? ` into ${binDir}` : ""}, which is not on your PATH yet.`);
81
- log(` run "uv tool update-shell" and open a new terminal.`);
112
+ if (binDir && platform !== "win32") {
113
+ log(` add it to PATH for this shell with:`);
114
+ log(` export PATH="${binDir}:$PATH"`);
115
+ log(` and run "uv tool update-shell" to make that permanent for new terminals.`);
116
+ } else {
117
+ log(` run "uv tool update-shell" and open a new terminal.`);
118
+ }
82
119
  return { status: "not-on-path" };
83
120
  }
84
121
 
85
122
  function warnManual(log, reason, platform) {
86
123
  log(` plain-parser: not installed — ${reason}`);
87
124
  log(` plain-forge's healthcheck needs it. Install it yourself with:`);
88
- log(` ${uvInstaller(platform).manual}`);
125
+ log(` ${uvManual(platform)}`);
89
126
  log(` uv tool install ${PLAIN_PARSER}`);
90
127
  }
91
128
 
@@ -93,6 +130,7 @@ function warnManual(log, reason, platform) {
93
130
  // Returns { status } — "present", "installed", "not-on-path", or "failed".
94
131
  function ensurePlainParser({
95
132
  platform = process.platform,
133
+ arch = process.arch,
96
134
  env = process.env,
97
135
  home = os.homedir(),
98
136
  run = runCommand,
@@ -107,9 +145,11 @@ function ensurePlainParser({
107
145
  let uv = findUv({ platform, env, home, run, exists });
108
146
  if (!uv) {
109
147
  log(` uv: not found — installing it (https://docs.astral.sh/uv/)`);
110
- const { cmd, args } = uvInstaller(platform);
111
- const r = run(cmd, args, { inherit: true });
112
- uv = !r.error && r.status === 0 ? findUv({ platform, env, home, run, exists }) : null;
148
+ for (const { cmd, args } of uvInstallers(platform, arch, env, home)) {
149
+ const r = run(cmd, args, { inherit: true });
150
+ uv = !r.error && r.status === 0 ? findUv({ platform, env, home, run, exists }) : null;
151
+ if (uv) break;
152
+ }
113
153
  if (!uv) {
114
154
  warnManual(log, "installing uv failed", platform);
115
155
  return { status: "failed" };
@@ -119,7 +159,7 @@ function ensurePlainParser({
119
159
  // Installed by an earlier run, but its bin dir is not on PATH.
120
160
  const binDir = run(uv, ["tool", "dir", "--bin"]).stdout.trim();
121
161
  const exe = platform === "win32" ? `${PLAIN_PARSER}.exe` : PLAIN_PARSER;
122
- if (binDir && exists(path.join(binDir, exe))) return warnOffPath(log, binDir);
162
+ if (binDir && exists(path.join(binDir, exe))) return warnOffPath(log, binDir, platform);
123
163
 
124
164
  log(` plain-parser: not found — installing it with uv`);
125
165
  // uv downloads a suitable Python (3.11+) itself when the host has none.
@@ -133,7 +173,7 @@ function ensurePlainParser({
133
173
  log(` plain-parser: installed`);
134
174
  return { status: "installed" };
135
175
  }
136
- return warnOffPath(log, binDir);
176
+ return warnOffPath(log, binDir, platform);
137
177
  }
138
178
 
139
- export { ensurePlainParser, uvInstaller, uvCandidates };
179
+ export { ensurePlainParser, uvInstallers, uvCandidates };
@@ -149,7 +149,7 @@ Add these to every requirement list regardless of project:
149
149
 
150
150
  - A shell matching the testing scripts' extension (Bash for `.sh`, PowerShell for `.ps1`).
151
151
  - `git` (the renderer uses it; almost every plain project tracks itself in git).
152
- - `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`.
152
+ - `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.
153
153
 
154
154
  ### Step 3 — Probe the host
155
155
 
@@ -76,7 +76,7 @@ Step 3 has two halves: a mechanical parse by `plain-parser`, then the checks tha
76
76
 
77
77
  [`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.
78
78
 
79
- 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.
79
+ 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.
80
80
  2. **Run it from the top module's own directory**, once per top module:
81
81
 
82
82
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plain-forge",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "description": "Conversational spec-writing tool for ***plain specification language",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "bin/cli.mjs",
30
30
  "bin/python-tools.mjs",
31
+ "bin/download-uv.mjs",
31
32
  "forge"
32
33
  ],
33
34
  "scripts": {