diff --git a/.agents/skills/check-glibc-floor/SKILL.md b/.agents/skills/check-glibc-floor/SKILL.md new file mode 100644 index 00000000..a7dc8ba7 --- /dev/null +++ b/.agents/skills/check-glibc-floor/SKILL.md @@ -0,0 +1,247 @@ +--- +name: check-glibc-floor +description: > + Verify the Linux glibc floor is consistent everywhere it is stated: the + release workflow's runner image is the source of truth, and scripts/install.sh, + docs/, and the release download labels must all agree with it. Also confirms + the statically linked musl CLI is still built and published. +--- + +# check-glibc-floor + +The prebuilt `*-linux-gnu` CLI and the desktop bundles are linked against the +glibc of the runner image that builds them. glibc versions its symbols, and the +compatibility runs one way only: a newer glibc runs older binaries, an older one +cannot run newer binaries. So that image's version is a hard floor -- below it +the binary exits with ``libc.so.6: version `GLIBC_2.34' not found`` before +running any code. + +That floor is a single fact restated across six files: `release.yml` itself, +`scripts/install.sh`, `scripts/release-downloads.mjs`, `docs/getting-started.md`, +`docs/troubleshooting.md`, and `CHANGES.md`. Several of them say it more than +once, so check every occurrence rather than counting them. This skill verifies +they all still say the same number, and that the musl escape hatch is still +shipped. + +**The source of truth is the Linux runner image in +`.github/workflows/release.yml`.** Nothing else. When they disagree, the image +is right and the other places are stale. + +## Runner image to glibc + +| Image | glibc | +|---|---| +| `ubuntu-20.04` | 2.31 (image retired; here for reading older workflows) | +| `ubuntu-22.04`, `ubuntu-22.04-arm` | 2.35 | +| `ubuntu-24.04`, `ubuntu-24.04-arm` | 2.39 | +| `ubuntu-latest` | whatever it currently aliases (2.39 at the time of writing) | + +If the image is not in this table, look up its glibc before continuing rather +than guessing, and add the row. + +## Steps + +### 1. Read the floor from the release workflow + +```bash +grep -n "os: ubuntu" .github/workflows/release.yml +``` + +Map every Linux leg through the table above. Expect one image across all Linux +legs; if the x64 and arm64 legs differ, the floor is the HIGHER of the two and +that divergence is itself a finding worth reporting. + +`ubuntu-latest` on a Linux leg is a finding on its own: it is a moving alias, so +it silently raises the floor whenever the platform re-points it. The Linux legs +must pin an explicit image. + +Call the result FLOOR (for example `2.35`). + +### 2. Verify scripts/install.sh + +```bash +grep -n "MIN_GLIBC_MAJOR\|MIN_GLIBC_MINOR" scripts/install.sh +``` + +- `MIN_GLIBC_MAJOR` and `MIN_GLIBC_MINOR` must equal FLOOR. + +Then confirm each of these is still present and wired up; every one of them is +load-bearing, and the script silently installs an unrunnable binary if any is +dropped: + +- `glibc_version` detects the host version, trying `getconf GNU_LIBC_VERSION` + first and `ldd --version` second, and yields nothing on a musl host. +- It rejects any value that is not a dotted number, because the comparison + below it is arithmetic. +- `linux_libc` compares against `MIN_GLIBC_*` and returns `musl` when the host + is below the floor, when there is no glibc at all, and when the version could + not be read. +- `SKILLKEEPER_LIBC` is validated at the TOP LEVEL of the script, not inside + `linux_libc`. `linux_libc` runs in a command substitution, where `err` would + exit only the subshell and leave the caller building a truncated target + triple. +- The Linux branch composes the triple from the detected libc, rather than + hardcoding `-gnu`. +- A missing musl asset falls back to the gnu archive with a warning (older + releases predate the musl archives), and that fallback is skipped when + `SKILLKEEPER_LIBC` named a flavour explicitly. +- The final smoke run points at `SKILLKEEPER_LIBC=musl` when the installed gnu + binary fails to start. + +Exercise the detection rather than only reading it. This stubs `getconf` and +`ldd` on PATH, sources the two real functions out of the script, and reads the +real `MIN_GLIBC_*`, so it tests the shipped threshold rather than a copy of it. +It runs anywhere, the developer's macOS included: + +```bash +d=$(mktemp -d) +# Anchored on the function syntax, not on comment text, and then checked: a +# range that failed to find its end would run to EOF and capture the whole +# install flow, which the `.` below would EXECUTE -- downloading and writing to +# $HOME, once per row. The two guards make that impossible rather than unlikely. +sed -n '/^glibc_version() {/,/^}/p;/^linux_libc() {/,/^}/p' scripts/install.sh > "$d/funcs.sh" +grep -qE 'download |INSTALL_DIR|asset_url|Done\.' "$d/funcs.sh" && + { echo "ABORT: extraction captured installer flow, do not source it"; exit 1; } +[ "$(grep -c '^}' "$d/funcs.sh")" = 2 ] || + { echo "ABORT: expected exactly 2 functions"; exit 1; } + +# Heredocs, not printf: a printf format string containing %s consumes it +# itself and writes a stub that ignores its argument, which silently turns +# every row below into "no glibc" and the whole table into musl. +cat > "$d/getconf" <<'EOF' +#!/bin/sh +[ "${1:-}" = GNU_LIBC_VERSION ] || exit 1 +[ -n "${SK_GLIBC:-}" ] || exit 1 +printf 'glibc %s\n' "$SK_GLIBC" +EOF +cat > "$d/ldd" <<'EOF' +#!/bin/sh +echo "musl libc (x86_64)" +EOF +chmod +x "$d/getconf" "$d/ldd" + +for v in 2.28 2.31 2.34 2.35 2.39 ""; do + out=$(SK_GLIBC="$v" PATH="$d:$PATH" SK_F="$d/funcs.sh" sh -c ' + eval "$(grep "^MIN_GLIBC_" scripts/install.sh)" + LIBC=auto + err() { echo "error: $1" >&2; exit 1; } + . "$SK_F" + linux_libc') + printf 'glibc %-6s -> %s\n' "${v:-none}" "$out" +done +rm -rf "$d" +``` + +Expected, for a floor of 2.35. The boundary is exercised from both sides, and +`none` stands for a host with no glibc at all, as on Alpine: + +``` +glibc 2.28 -> musl +glibc 2.31 -> musl +glibc 2.34 -> musl +glibc 2.35 -> gnu +glibc 2.39 -> gnu +glibc none -> musl +``` + +Any `ABORT` line, or a table differing from this one, is a FAIL: either the +detection changed or the extraction no longer matches the functions. Do not +adjust the test to make it pass. Also run `sh -n scripts/install.sh`. + +### 3. Verify the musl builds are still published + +```bash +grep -n "musl" .github/workflows/release.yml +``` + +- Both Linux legs carry a `musl_target` (`x86_64-unknown-linux-musl` and + `aarch64-unknown-linux-musl`); every non-Linux leg carries an empty one. +- The toolchain step installs the musl target alongside the leg's own. +- A `Build the CLI (static, musl)` step and its `tar.gz` archive step both run + when `musl_target` is non-empty, and the archive lands in `dist-cli/`, which + the upload step collects. + +The musl archive is published ALONGSIDE the gnu one, never instead of it: +`SKILLKEEPER_VERSION` can pin an older release, and existing installs resolve +the asset name they already know. + +### 4. Verify the documentation states FLOOR + +```bash +grep -rn "glibc" docs/ README.md CHANGES.md +``` + +`CHANGES.md` is in scope deliberately: it states the floor in the entry that +introduced the musl builds, and a runner-image bump would otherwise leave the +changelog quietly asserting the old number while this gate reported CONSISTENT. + +Every stated version must equal FLOOR. At the time of writing that is: + +- `docs/getting-started.md` -- the System requirements table (CLI and desktop + rows) and the install-script paragraph. +- `docs/troubleshooting.md` -- the `GLIBC_2.34' not found` section. +- `CHANGES.md` -- the musl entry under the current version. + +Distribution versions named as remedies are part of this check, not decoration: +verify each one actually clears FLOOR before repeating it. RHEL 9, for +instance, ships glibc 2.34 and does NOT clear a floor of 2.35. + +The troubleshooting section must still say all of: the download is not corrupt, +the musl build is the fix, `SKILLKEEPER_LIBC=musl` is the command, building from +source also works, and upgrading glibc in place is not the answer (the +distribution release pins it). Removing the last point invites someone to break +a machine. + +### 5. Verify the release download labels + +```bash +grep -n "note:" scripts/release-downloads.mjs +``` + +The two `*-linux-gnu` entries must carry a `glibc +` note and the two +`*-linux-musl` entries a `static, musl` note, so a reader picking an asset by +hand can tell which one runs on their host. All four Linux triples must be in +`CLI_TARGETS`; an unlisted one degrades to a bare triple label. + +### 6. Check whether the floor rose since the last release + +This is the one case that reaches users who already have the app installed. + +```bash +git diff "$(git describe --tags --abbrev=0)" -- .github/workflows/release.yml | grep -E "^[+-].*os: ubuntu" +``` + +No `..HEAD` on purpose: that form compares committed history only, so during +release prep -- exactly when this skill runs -- a runner-image bump still +sitting in the working tree would read as "floor unchanged". The two-dot-free +form covers committed and uncommitted alike. No output means the image did not +move. + +If the Linux runner image changed since the last release tag, say so loudly and +report the old and new floors. A raised floor means hosts between the two +versions run the current desktop app fine but cannot run the next one, and the +self-updater has no glibc gate: `platform.rs::asset_key` selects on os/arch +only, and `versions.json` records no minimum (see `scanAssets` in +`scripts/gen-versions-json.mjs`). Those hosts would download, install, and end +up with an app that will not start. + +Raising the floor is therefore a release decision, not an incidental bump. Flag +it for the developer; do not decide it inside this skill. + +### 7. Report + +``` +runner image / floor: ubuntu-22.04 -> glibc 2.35 +install.sh MIN_GLIBC_*: PASS / FAIL +install.sh detection: PASS / FAIL +musl builds published: PASS / FAIL +docs state the floor: PASS / FAIL +download labels: PASS / FAIL +floor unchanged since tag: PASS / RAISED (2.35 -> 2.39) + +glibc floor: CONSISTENT / INCONSISTENT +``` + +List every mismatch with its file and line and the value it should hold. Do not +edit files automatically -- propose the corrections and let the developer apply +them. diff --git a/.agents/skills/pre-release-check/SKILL.md b/.agents/skills/pre-release-check/SKILL.md index 948fbe29..ec38345a 100644 --- a/.agents/skills/pre-release-check/SKILL.md +++ b/.agents/skills/pre-release-check/SKILL.md @@ -2,9 +2,9 @@ name: pre-release-check description: > Gate a release by running check-changes, check-docs, run-tests-and-linters, - check-licenses, and check-fixture-repo, plus verifying the version bump and - that all commits since the last release follow the conventional-commits - format. + check-licenses, check-fixture-repo, and check-glibc-floor, plus verifying the + version bump and that all commits since the last release follow the + conventional-commits format. --- # pre-release-check @@ -13,7 +13,7 @@ Run the full release gate. All checks must pass before tagging a release. ## Steps -### 1. Run the five component skills +### 1. Run the six component skills Run each skill in order and collect its result (PASS or FAIL with details): @@ -25,7 +25,13 @@ Run each skill in order and collect its result (PASS or FAIL with details): against a real working tree, so it catches wiring regressions the unit tests (which run against an in-memory filesystem) cannot see. 4. **check-docs** -- README.md, docs/ nav, command accuracy, version refs. -5. **check-changes** -- CHANGES.md Development section vs. commit history. +5. **check-glibc-floor** -- the Linux glibc floor agrees across the release + workflow, `scripts/install.sh`, docs/, and the download labels, and the musl + CLI is still published. Belongs in the release gate because the floor is set + by the runner image the release itself builds on: a bumped image quietly + invalidates every stated version, and a raised floor strands hosts that the + self-updater will still offer the update to. +6. **check-changes** -- CHANGES.md Development section vs. commit history. If check-licenses, run-tests-and-linters, or check-fixture-repo fails, report the failure and stop. The remaining checks can still be reported for completeness, @@ -86,6 +92,7 @@ check-licenses: PASS / FAIL run-tests-and-linters: PASS / FAIL check-fixture-repo: PASS / FAIL check-docs: PASS / FAIL +check-glibc-floor: PASS / FAIL check-changes: PASS / FAIL tag provenance (branch): PASS / FAIL version bump consistent: PASS / FAIL diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bede7f26..9d7a818c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,11 @@ jobs: uses: dtolnay/rust-toolchain@4716b85f2fac3e324e64fa2810f6b5c3905760a5 # 1.97.1 with: components: rustfmt, clippy + # The two musl targets the release builds the static CLI for, in the + # same comma-separated form release.yml passes. Installing them here + # means a broken toolchain pin or a mistyped target list fails on a + # pull request instead of on the tag, where it would kill the release. + targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl - name: Cache cargo registry and target uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -208,6 +213,22 @@ jobs: - name: Test workspace run: cargo test --workspace + # The release publishes a statically linked CLI for Linux (see the + # "Build the CLI (static, musl)" step in release.yml). Without this, the + # first musl link of any change happens on the tag -- and a failure there + # stops `publish`, so the tag is dead and has to be deleted and re-cut. + # + # x64 gets a full `build`, which actually links, because that is what the + # release does. arm64 gets `check`: `cargo check` never invokes the + # linker, so it cross-compiles here without an aarch64 musl linker, while + # still proving the target is installed and the tree compiles for it. + # Only the CLI is built -- the desktop app links the system WebKitGTK and + # cannot be static. + - name: Build the CLI for musl (release parity) + run: | + cargo build -p skillkeeper-cli --target x86_64-unknown-linux-musl + cargo check -p skillkeeper-cli --target aarch64-unknown-linux-musl + # `cargo test` WRITES the generated artifacts (ts-rs bindings, the mcp.yml # JSON Schema) rather than asserting them, so a commit that changes a Rust # type but forgets to commit the regenerated file is green here and only diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00dec0fc..6d7cc9b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,35 +111,47 @@ jobs: fail-fast: false matrix: include: + # `musl_target` is the extra, statically linked CLI-only target this + # leg also builds. It is empty everywhere but Linux: the desktop + # bundle links the system WebKitGTK stack and cannot be static, and + # only glibc's symbol versioning creates the problem musl solves. + # See the "Build the CLI (static)" step for the full reasoning. + # # macOS Apple Silicon - os: macos-latest target: aarch64-apple-darwin args: '--target aarch64-apple-darwin' + musl_target: '' # macOS Intel (cross-compiled on the arm64 runner) - os: macos-latest target: x86_64-apple-darwin args: '--target x86_64-apple-darwin' + musl_target: '' # Linux x64. ubuntu-22.04 keeps the AppImage's glibc floor low for # broader distro compatibility. - os: ubuntu-22.04 target: x86_64-unknown-linux-gnu args: '' + musl_target: x86_64-unknown-linux-musl # Linux arm64, built natively on the arm64 runner (free for public # repositories). Same 22.04 image as the x64 leg, so both AppImages # share a glibc floor. - os: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu args: '' + musl_target: aarch64-unknown-linux-musl # Windows x64 - os: windows-latest target: x86_64-pc-windows-msvc args: '' + musl_target: '' # Windows arm64 (cross-compiled on the x64 runner, as macOS Intel is). # Only the Rust target changes; Node, pnpm and the frontend build stay # on the x64 image. - os: windows-latest target: aarch64-pc-windows-msvc args: '--target aarch64-pc-windows-msvc' + musl_target: '' steps: - name: Checkout repository @@ -167,6 +179,14 @@ jobs: # documented subset. A missing patchelf fails the arm64 leg, and # `publish` needs every leg -- so the whole release is blocked to save # one small download. + # Deliberately no musl-tools. rustc links the musl targets + # self-contained (it ships musl's libc.a and crt objects with + # rust-std) and drives the link through the plain host `cc`, so the + # pure-Rust CLI never invokes musl-gcc. Nor would the package buy + # future-proofing: if a crate in that tree ever grew a C dependency, + # `cc` looks for `aarch64-linux-musl-gcc` on the arm64 leg, which + # musl-tools does not ship -- that case needs a real per-arch musl + # cross toolchain, not this. sudo apt-get install -y -o DPkg::Lock::Timeout=300 \ patchelf \ libwebkit2gtk-4.1-dev \ @@ -176,7 +196,8 @@ jobs: - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@4716b85f2fac3e324e64fa2810f6b5c3905760a5 # 1.97.1 with: - targets: ${{ matrix.target }} + # Both this leg's target and, on Linux, its static musl companion. + targets: ${{ matrix.musl_target != '' && format('{0},{1}', matrix.target, matrix.musl_target) || matrix.target }} - name: Cache cargo registry and target uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -442,6 +463,39 @@ jobs: tar -czf "dist-cli/skillkeeper-cli-${{ matrix.target }}.tar.gz" \ -C "target/${{ matrix.target }}/release" skillkeeper + # ----------------------------------------------------------------------- + # Second Linux CLI: statically linked against musl. + # + # The gnu binary above is linked on this runner's glibc (2.35 on the + # 22.04 images). glibc versions its symbols and is only forward + # compatible, so that binary will not start on an older host -- it dies + # with "libc.so.6: version `GLIBC_2.34' not found" before main() runs. + # A musl build carries its own libc and depends on nothing, which puts + # the whole distro question to rest for the CLI. + # + # This costs one extra `cargo build` and no new tooling: every crate in + # the CLI's tree is pure Rust (git is a subprocess, there is no TLS or + # OpenSSL), so the static link needs no C sysroot. It is published + # ALONGSIDE the gnu archive, not instead of it -- install.sh picks + # between them from the host's glibc, and existing pinned installs keep + # resolving the asset name they already know. + # + # Keep the glibc floor in scripts/install.sh and docs/ in step with the + # runner image above; the `check-glibc-floor` skill verifies that. + # ----------------------------------------------------------------------- + - name: Build the CLI (static, musl) + if: matrix.musl_target != '' + shell: bash + run: cargo build --release -p skillkeeper-cli --target ${{ matrix.musl_target }} + + - name: Archive the CLI (tar.gz, musl) + if: matrix.musl_target != '' + shell: bash + run: | + mkdir -p dist-cli + tar -czf "dist-cli/skillkeeper-cli-${{ matrix.musl_target }}.tar.gz" \ + -C "target/${{ matrix.musl_target }}/release" skillkeeper + - name: Archive the CLI (zip) if: matrix.os == 'windows-latest' shell: pwsh @@ -529,6 +583,34 @@ jobs: echo "Staged $(find dist -type f | wc -l) files:" ls -1 dist + # The staging step above only rejects DUPLICATE basenames, and + # upload-artifact is set to `warn` on no files found -- so a matrix or + # `if:` regression that silently skips one archive step would publish a + # release missing that asset, and nothing would fail. That is not a + # cosmetic gap: scripts/install.sh reacts to a missing musl archive by + # falling back to the glibc one with a warning, so an old-distribution + # host would install a binary that cannot start. Name every CLI archive + # the two installers can ask for and fail the release if one is absent. + - name: Verify every CLI archive was built + run: | + missing='' + for a in \ + skillkeeper-cli-aarch64-apple-darwin.tar.gz \ + skillkeeper-cli-x86_64-apple-darwin.tar.gz \ + skillkeeper-cli-x86_64-unknown-linux-gnu.tar.gz \ + skillkeeper-cli-aarch64-unknown-linux-gnu.tar.gz \ + skillkeeper-cli-x86_64-unknown-linux-musl.tar.gz \ + skillkeeper-cli-aarch64-unknown-linux-musl.tar.gz \ + skillkeeper-cli-x86_64-pc-windows-msvc.zip \ + skillkeeper-cli-aarch64-pc-windows-msvc.zip; do + [ -f "dist/$a" ] || missing="$missing $a" + done + if [ -n "$missing" ]; then + for a in $missing; do echo "::error::Missing release asset: $a"; done + exit 1 + fi + echo "All 8 CLI archives present." + # The mcp.yml JSON Schema is generated from the Rust types by `cargo test` # and committed, so it ships straight from the checkout -- no build leg # produces it. It must be attached to EVERY release: the docs tell editors diff --git a/AGENTS.md b/AGENTS.md index 1c57784f..059da292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,7 +390,7 @@ backend events via `listen`. It imports the ts-rs-generated types under ## Local Development Skills -Seven skills live under `.agents/skills/`. Invoke them when the situation calls +Eight skills live under `.agents/skills/`. Invoke them when the situation calls for it: | Skill | When to use | @@ -401,7 +401,8 @@ for it: | `run-tests-and-linters` | Before marking any task done -- run the full gate (lint, typecheck, test:cov at 90%). | | `check-fixture-repo` | After touching resolution, install, hooks, guidance, or MCP -- drive the built CLI against the `examples/test-repo` fixture end to end, in a throwaway state dir. The only check that exercises the real binary against a real working tree. | | `check-licenses` | After editing any `package.json` or `Cargo.toml` -- verify all npm and cargo dependencies are license-compliant and update LICENSE. | -| `pre-release-check` | Before cutting a release -- runs the five `check-*` and `run-*` skills above (not `bump-version`) plus version-bump and commit-format checks. | +| `check-glibc-floor` | After touching the release workflow's Linux legs, `scripts/install.sh`, or the stated system requirements -- verify the glibc floor agrees everywhere it is written and the static musl CLI is still published. The runner image is the source of truth. | +| `pre-release-check` | Before cutting a release -- runs the six `check-*` and `run-*` skills above (not `bump-version`) plus version-bump and commit-format checks. | --- diff --git a/CHANGES.md b/CHANGES.md index 9d66580e..94dc22ff 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -40,6 +40,25 @@ ## Development +## Version 0.7.2 + +### Added + +- Statically linked musl builds of the CLI for Linux x64 and arm64 + (`skillkeeper-cli-*-unknown-linux-musl.tar.gz`), published alongside the + existing glibc ones. They depend on no system libc, so the CLI now runs on + distributions below glibc 2.35. + +### Fixed + +- The install script picks the Linux build from the host's own glibc, instead of + always downloading the glibc one and leaving older distributions with a binary + that exits with ``GLIBC_2.34' not found``. Override the choice with + `SKILLKEEPER_LIBC=gnu` or `SKILLKEEPER_LIBC=musl`. The desktop app is + unaffected and keeps its glibc 2.35 floor: it links the distribution's + WebKitGTK, so it has no static build. See + [System requirements](https://lorem-dev.github.io/skillkeeper/latest/getting-started/#system-requirements). + ## Version 0.7.1 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 47a2c391..c9c9106c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4235,7 +4235,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skillkeeper" -version = "0.7.1" +version = "0.7.2" dependencies = [ "aes 0.9.3", "argon2", @@ -4270,14 +4270,14 @@ dependencies = [ [[package]] name = "skillkeeper-agents" -version = "0.7.1" +version = "0.7.2" dependencies = [ "skillkeeper-core", ] [[package]] name = "skillkeeper-cli" -version = "0.7.1" +version = "0.7.2" dependencies = [ "clap", "serde", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "skillkeeper-config" -version = "0.7.1" +version = "0.7.2" dependencies = [ "serde", "serde_json", @@ -4302,7 +4302,7 @@ dependencies = [ [[package]] name = "skillkeeper-core" -version = "0.7.1" +version = "0.7.2" dependencies = [ "hex", "regex", diff --git a/Cargo.toml b/Cargo.toml index a73910f4..39d12770 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ [workspace.package] edition = "2021" -version = "0.7.1" +version = "0.7.2" license = "Apache-2.0" [workspace.dependencies] @@ -23,4 +23,15 @@ hex = "0.4" thiserror = "2" uuid = { version = "1", features = ["v4"] } regex = "1" -ts-rs = "12" +# `no-serde-warnings` silences one diagnostic class: ts-rs warns and skips when +# it meets a serde attribute it cannot model, and `McpParameter::options` uses +# `deserialize_with` (see `mcp::model::de_options`) which ts-rs has no concept +# of. Skipping it is correct -- the field always SERIALIZES back as a plain +# list, so the emitted `Array` is right -- but the warning printed on +# every `cargo clippy` run regardless. +# +# Nothing is lost by silencing it. The generated bindings are committed and CI's +# "Fail on stale generated artifacts" step diffs them, so if ts-rs ever emits a +# different shape for this field the build fails with an error instead of a +# warning nobody reads. +ts-rs = { version = "12", features = ["no-serde-warnings"] } diff --git a/README.md b/README.md index 0c86b27c..bcdde52a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,9 @@ for updates. Supported agents: Claude, Codex, Copilot, Cursor, and OpenCode. **CLI** (`skillkeeper`) -- one line, using only tools already on your system. The script detects your platform, downloads the matching CLI archive from the latest -release, and adds the binary to your PATH. +release, and adds the binary to your PATH. On Linux it reads the host's glibc and +picks between the glibc build and the statically linked musl one, so it installs +a binary that runs on old distributions too. macOS / Linux: @@ -61,8 +63,9 @@ irm https://raw.githubusercontent.com/lorem-dev/skillkeeper/main/scripts/install See [Getting Started](https://lorem-dev.github.io/skillkeeper/latest/getting-started/) for other options. -Hitting an install problem (for example macOS reporting the app as "damaged")? -See [Troubleshooting](https://lorem-dev.github.io/skillkeeper/latest/troubleshooting/). +Hitting an install problem -- macOS reporting the app as "damaged", or a Linux +``GLIBC_2.34' not found`` on start? See +[Troubleshooting](https://lorem-dev.github.io/skillkeeper/latest/troubleshooting/). --- diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ae4e3d41..fdd18701 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@skillkeeper/desktop", - "version": "0.7.1", + "version": "0.7.2", "private": true, "description": "Install and manage skills and hooks for AI coding agents", "author": { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index e1945134..51ed4b9b 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "SkillKeeper", - "version": "0.7.1", + "version": "0.7.2", "identifier": "dev.lorem.skillkeeper", "build": { "beforeDevCommand": "pnpm exec vite", diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx b/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx index 7944f100..13591178 100644 --- a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx @@ -20,13 +20,10 @@ * `DescriptionSpan[]` value satisfies it structurally. */ import { cx } from '../../lib'; +import { spansToKeyedParts } from './spansToKeyedParts'; +import type { DescriptionSpan } from './spansToKeyedParts'; import './DescriptionText.scss'; -/** One piece of a parsed description: plain text, or a link with its own - * display text and target url. Structurally identical to the backend's - * generated `DescriptionSpan`. */ -export type DescriptionSpan = { kind: 'text'; text: string } | { kind: 'link'; text: string; url: string }; - export interface DescriptionTextProps { readonly spans: readonly DescriptionSpan[]; /** Called with a link span's own `url` when its button is clicked. Never @@ -40,15 +37,6 @@ export interface DescriptionTextProps { readonly 'data-testid'?: string; } -/** One span plus a stable React key. Keyed by position: spans never reorder - * once parsed, so a position-based key stays distinct even when two link - * spans repeat the same text and url. */ -export type KeyedDescriptionSpan = DescriptionSpan & { readonly key: string }; - -export function spansToKeyedParts(spans: readonly DescriptionSpan[]): KeyedDescriptionSpan[] { - return spans.map((span, index) => ({ ...span, key: String(index) })); -} - export function DescriptionText({ spans, onOpenLink, className, 'data-testid': testId }: DescriptionTextProps) { return ( diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/index.ts b/apps/desktop/src/renderer/shared/ui/DescriptionText/index.ts index 68bbeb1b..1e7d09a8 100644 --- a/apps/desktop/src/renderer/shared/ui/DescriptionText/index.ts +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/index.ts @@ -1,2 +1,3 @@ export { DescriptionText } from './DescriptionText'; -export type { DescriptionTextProps, DescriptionSpan } from './DescriptionText'; +export type { DescriptionTextProps } from './DescriptionText'; +export type { DescriptionSpan } from './spansToKeyedParts'; diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.test.ts b/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.test.ts similarity index 91% rename from apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.test.ts rename to apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.test.ts index 6c1790a7..1d28c6af 100644 --- a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.test.ts +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { spansToKeyedParts } from './DescriptionText'; -import type { DescriptionSpan } from './DescriptionText'; +import { spansToKeyedParts } from './spansToKeyedParts'; +import type { DescriptionSpan } from './spansToKeyedParts'; describe('spansToKeyedParts', () => { it('keeps text and link spans in order with stable keys', () => { diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.ts b/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.ts new file mode 100644 index 00000000..7627c063 --- /dev/null +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.ts @@ -0,0 +1,29 @@ +/** + * The description span model and its React keying. + * + * Kept out of `DescriptionText.tsx` for two reasons that point the same way. + * Renderer tests here are node-only -- no jsdom, no testing-library -- so a + * component cannot be unit tested and pure logic has to live where a test can + * reach it. And exporting a function from a file that also exports a component + * breaks fast refresh, which is what `react-refresh/only-export-components` + * reported for as long as this lived there. + * + * Same split, for the same reason, as + * `features/skillInstall/lib/installSelection.ts`. + */ + +/** One piece of a parsed description: plain text, or a link with its own + * display text and target url. Structurally identical to the backend's + * generated `DescriptionSpan`, declared locally so this generic component has + * no dependency on the `services` layer -- any concrete `DescriptionSpan[]` + * value satisfies it. */ +export type DescriptionSpan = { kind: 'text'; text: string } | { kind: 'link'; text: string; url: string }; + +/** One span plus a stable React key. Keyed by position: spans never reorder + * once parsed, so a position-based key stays distinct even when two link + * spans repeat the same text and url. */ +export type KeyedDescriptionSpan = DescriptionSpan & { readonly key: string }; + +export function spansToKeyedParts(spans: readonly DescriptionSpan[]): KeyedDescriptionSpan[] { + return spans.map((span, index) => ({ ...span, key: String(index) })); +} diff --git a/docs/getting-started.md b/docs/getting-started.md index b105ddd7..f42dcbcf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,6 +11,20 @@ Node.js 24+ and pnpm 11 are only needed to build the desktop app's renderer, not the CLI. +## System requirements + +| Platform | Requirement | +|---|---| +| Linux, CLI | Any distribution. Two builds ship: glibc (needs glibc 2.35 or newer) and static musl (needs no system libc). The install script picks one. | +| Linux, desktop app | glibc 2.35 or newer, plus WebKitGTK 4.1 and libsoup 3. No static build: the app links that stack from the distribution. | +| macOS | 11 or newer, Intel or Apple Silicon. | +| Windows | 10 or newer, x64 or arm64. | + +The glibc floor is where the release binaries are linked, not a policy choice; +check yours with `ldd --version`. Building from source links against the glibc +the host already has, so its floor is the Rust toolchain's own, far lower. For a +host below the floor, see [Troubleshooting](troubleshooting.md). + ## Installation SkillKeeper ships two front ends over the same core: @@ -43,6 +57,10 @@ irm https://raw.githubusercontent.com/lorem-dev/skillkeeper/main/scripts/install Override the install directory with `SKILLKEEPER_INSTALL_DIR`, or pin a specific release with `SKILLKEEPER_VERSION` (for example `v0.1.1`). +On Linux it also reads the host's glibc and takes the static musl build when +that is below 2.35 or absent, as on Alpine. Both builds are the same CLI; +override the choice with `SKILLKEEPER_LIBC=gnu` or `SKILLKEEPER_LIBC=musl`. + ### CLI: build from source The `skillkeeper` CLI is a Rust binary in this workspace. Build it with cargo: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b8632482..50ae34d4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -66,6 +66,40 @@ Make sure the install directory is on your PATH, then verify with `skillkeeper version`. See the [CLI Reference](usage/cli.md) for the available commands. +### Linux: "version `GLIBC_2.34' not found" when running skillkeeper + +``` +/home/you/.local/bin/skillkeeper: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /home/you/.local/bin/skillkeeper) +``` + +The install worked and the binary is on disk; it cannot start. The glibc build +of the CLI is linked against glibc 2.35, and a newer glibc runs older binaries +but not the reverse, so it needs 2.35 or newer. Nothing is corrupt and +reinstalling changes nothing. + +Use the statically linked musl build instead, which carries its own libc: + +```shell +curl -fsSL https://raw.githubusercontent.com/lorem-dev/skillkeeper/main/scripts/install.sh | SKILLKEEPER_LIBC=musl sh +``` + +Current releases pick that build automatically, so the variable is only there to +override a wrong guess. A release older than the musl archives has no musl build +to install at all; there, build from source +(`cargo install --path crates/skillkeeper-cli`), which links against the glibc +you already have. + +Upgrading glibc in place is not the fix: the distribution release pins the +version and every binary on the system links it, so the supported route is a +full distribution upgrade using that distribution's own tool (Ubuntu 20.04 to +22.04 or later, Debian 11 to 12; note that even RHEL 9 ships 2.34, below the +floor). Installing glibc from a third-party repository or building it into +`/usr` is a known way to leave userspace unusable and needing a rescue +environment. On a host you cannot upgrade, use the musl build. + +The desktop app has the same floor and no musl build, because it links the +distribution's WebKitGTK. + ## Skills ### An orange `!` marker on an installed skill diff --git a/package.json b/package.json index 14810780..b4aaad41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skillkeeper", - "version": "0.7.1", + "version": "0.7.2", "private": true, "type": "module", "description": "Install and manage skills and hooks for AI coding agents", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index ca5debf8..e3730efc 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@skillkeeper/i18n", - "version": "0.7.1", + "version": "0.7.2", "private": true, "author": "Lorem Dev", "homepage": "https://lorem-dev.github.io/skillkeeper/", diff --git a/scripts/install.sh b/scripts/install.sh index 76d02229..2d45ed22 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,6 +9,7 @@ # Environment overrides: # SKILLKEEPER_VERSION release tag to install (default: latest) # SKILLKEEPER_INSTALL_DIR install directory (default: $HOME/.local/bin) +# SKILLKEEPER_LIBC Linux libc flavour: auto (default), gnu, or musl # # Windows users: use scripts/install.ps1 instead. set -eu @@ -17,12 +18,127 @@ REPO="lorem-dev/skillkeeper" BIN="skillkeeper" INSTALL_DIR="${SKILLKEEPER_INSTALL_DIR:-$HOME/.local/bin}" VERSION="${SKILLKEEPER_VERSION:-latest}" +LIBC="${SKILLKEEPER_LIBC:-auto}" + +# Lowest glibc the *-linux-gnu CLI can run on. It is the glibc of the runner +# image that links it in the release workflow (ubuntu-22.04 -> glibc 2.35): +# glibc versions its symbols, and a newer glibc runs older binaries but not the +# reverse, so a binary linked on 2.35 dies on an older host with +# "libc.so.6: version `GLIBC_2.34' not found" before it reaches main(). Hosts +# below the floor get the statically linked musl build instead, which has no +# libc dependency at all. +# +# Bump these together with the runner image in .github/workflows/release.yml +# and the requirements in docs/; the `check-glibc-floor` skill verifies that +# the three agree. +MIN_GLIBC_MAJOR=2 +MIN_GLIBC_MINOR=35 + +# Initialized before anything reads them. POSIX sh has no `local`, so without +# this an inherited environment variable named `libc` or `cpu` would reach the +# Linux-only logic below -- on macOS that drove the Linux fallback branch and +# then died on an unset `cpu`. +cpu="" +libc="" err() { printf 'error: %s\n' "$1" >&2 exit 1 } +warn() { + printf 'warning: %s\n' "$1" >&2 +} + +# Validated here rather than where it is used: `linux_libc` runs inside a +# command substitution, and an `err` there would exit only that subshell, +# leaving the caller to carry on with an empty answer. +case "$LIBC" in + auto | gnu | musl) ;; + *) err "SKILLKEEPER_LIBC must be auto, gnu or musl (got: $LIBC)" ;; +esac + +# Echo the host's glibc as "major.minor", or nothing at all when this is not +# glibc (a musl distro such as Alpine, where the musl build is the only +# option anyway). +glibc_version() { + v="" + if command -v getconf >/dev/null 2>&1; then + # A glibc-only variable: musl's getconf does not define it, so an empty + # answer here is itself informative. The redirect wraps the whole pipeline, + # not just getconf, so a host without awk cannot print at the user either. + v="$({ getconf GNU_LIBC_VERSION | awk '{ print $NF }'; } 2>/dev/null)" + fi + if [ -z "$v" ] && command -v ldd >/dev/null 2>&1; then + # Read from the whole output, not from line 1: on Debian and Ubuntu `ldd` + # is a bash script, and a host with an unconfigured locale makes bash print + # a setlocale warning ahead of it, which would hide both the musl marker + # and the version. musl's ldd says "musl libc" and carries no glibc + # version, so matching it anywhere leaves `v` empty -- the right answer. + # + # Every glibc ldd puts the version last on its `ldd (...) X.Y` line + # ("ldd (Ubuntu GLIBC 2.31-0ubuntu9.9) 2.31" -> 2.31), so take that field + # rather than the first version-shaped text on the line. + out="$(ldd --version 2>&1 || true)" + case "$out" in + *musl*) : ;; + *) v="$(printf '%s\n' "$out" | awk '/^ldd /{ print $NF; exit }')" ;; + esac + fi + + # Everything below compares with `-ge`, which must never be handed a + # surprise: accept only a dotted number, and keep just the first two parts. + case "$v" in + *.*) ;; + *) return 0 ;; + esac + major="${v%%.*}" + minor="${v#*.}" + minor="${minor%%.*}" + # Each part separately -- concatenating them would let an empty component + # ("2." -> major=2, minor="") pass and reach `test`, which answers with + # "Illegal number" on the user's terminal. Five digits or more is likewise + # not a glibc version and would overflow the comparison. + for part in "$major" "$minor"; do + case "$part" in + '' | *[!0-9]* | ?????*) return 0 ;; + esac + done + printf '%s.%s' "$major" "$minor" +} + +# Echo "gnu" or "musl" for this Linux host. +linux_libc() { + if [ "$LIBC" != "auto" ]; then + printf '%s' "$LIBC" + return 0 + fi + + v="$(glibc_version)" + if [ -z "$v" ]; then + printf 'musl' + return 0 + fi + major="${v%%.*}" + minor="${v#*.}" + if [ "$major" -gt "$MIN_GLIBC_MAJOR" ] || + { [ "$major" -eq "$MIN_GLIBC_MAJOR" ] && [ "$minor" -ge "$MIN_GLIBC_MINOR" ]; }; then + printf 'gnu' + else + printf 'musl' + fi +} + +# The `releases/latest/download/` path always redirects to the newest +# release, so a plain download needs no API call or extra tooling. +asset_url() { + if [ "$VERSION" = "latest" ]; then + printf 'https://github.com/%s/releases/latest/download/%s' "$REPO" "$1" + else + printf 'https://github.com/%s/releases/download/%s/%s' "$REPO" "$VERSION" "$1" + fi +} + # Detect OS + architecture and map them to the Rust target triple used in the # release asset names (skillkeeper-cli-.tar.gz). os="$(uname -s)" @@ -37,10 +153,13 @@ case "$os" in ;; Linux) case "$arch" in - x86_64 | amd64) target="x86_64-unknown-linux-gnu" ;; - aarch64 | arm64) target="aarch64-unknown-linux-gnu" ;; + x86_64 | amd64) cpu="x86_64" ;; + aarch64 | arm64) cpu="aarch64" ;; *) err "no prebuilt CLI for Linux $arch (build from source: cargo install --path crates/skillkeeper-cli)" ;; esac + # Two Linux builds ship per architecture; pick by the host's own glibc. + libc="$(linux_libc)" + target="${cpu}-unknown-linux-${libc}" ;; *) err "unsupported OS: $os (on Windows use scripts/install.ps1)" @@ -48,20 +167,22 @@ case "$os" in esac asset="skillkeeper-cli-${target}.tar.gz" +url="$(asset_url "$asset")" -# The `releases/latest/download/` path always redirects to the newest -# release, so a plain download needs no API call or extra tooling. -if [ "$VERSION" = "latest" ]; then - url="https://github.com/${REPO}/releases/latest/download/${asset}" -else - url="https://github.com/${REPO}/releases/download/${VERSION}/${asset}" -fi - -# Use whichever downloader is already installed. +# Use whichever downloader is already installed. `http_error` reports whether a +# failure was the server answering "no such asset" as opposed to the request +# never getting there -- the fallback below must not treat a flaky network as +# proof that an asset does not exist. if command -v curl >/dev/null 2>&1; then download() { curl -fsSL "$1" -o "$2"; } + # With -f, curl exits 22 on an HTTP error response. Every other code is + # transport (6 DNS, 7 connect, 28 timeout, ...). + http_error() { [ "$1" = 22 ]; } elif command -v wget >/dev/null 2>&1; then download() { wget -qO "$2" "$1"; } + # wget exits 8 when the server issued an error response, 4 on a network + # failure. + http_error() { [ "$1" = 8 ]; } else err "need curl or wget on PATH to download the release" fi @@ -70,7 +191,34 @@ tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT INT TERM printf 'Downloading %s ...\n' "$asset" -download "$url" "$tmp/$asset" || err "download failed: $url" +# `|| rc=$?` rather than `if ! download`: `!` would make the status inside the +# branch 0, and the branch needs the real code to tell 404 from a dead network. +rc=0 +download "$url" "$tmp/$asset" || rc=$? +if [ "$rc" -ne 0 ]; then + # Releases cut before the musl builds existed carry only the gnu archive, and + # SKILLKEEPER_VERSION can still pin one. Fall back to it rather than failing + # outright -- but say plainly that it may not start here, so a GLIBC error a + # moment later is not a mystery. Only on a genuine HTTP error, and never when + # the flavour was named explicitly: that choice deserves a hard failure. + if [ "$libc" = "musl" ] && [ "$LIBC" = "auto" ] && http_error "$rc"; then + # "could not be fetched", not "is not in this release": the codes checked + # above cover every HTTP error, so this is usually a missing asset on an + # older release but can also be a 5xx or a rate limit. + warn "$asset could not be fetched; falling back to the glibc build" + warn "it needs glibc ${MIN_GLIBC_MAJOR}.${MIN_GLIBC_MINOR} or newer and may not run on this host" + asset="skillkeeper-cli-${cpu}-unknown-linux-gnu.tar.gz" + url="$(asset_url "$asset")" + # The gnu archive is what is being installed from here on, so the smoke run + # at the end must give the GLIBC hint. Without this it stayed "musl" and + # suppressed the hint in the one case it exists for. + libc="gnu" + printf 'Downloading %s ...\n' "$asset" + download "$url" "$tmp/$asset" || err "download failed: $url" + else + err "download failed: $url" + fi +fi printf 'Extracting ...\n' tar -xzf "$tmp/$asset" -C "$tmp" || err "failed to extract $asset" @@ -110,4 +258,17 @@ case ":${PATH}:" in esac printf 'Done. ' -"$INSTALL_DIR/$BIN" version || true +# A binary that cannot start says so here, in the shape of a linker error that +# reads like a corrupt download. Name the actual remedy instead of leaving the +# reader with "GLIBC_2.34 not found". +if ! "$INSTALL_DIR/$BIN" version; then + printf '\n' + warn "the binary was installed but did not run (see the output above)" + if [ "$libc" = "gnu" ]; then + warn "if that mentions GLIBC, this host is older than glibc ${MIN_GLIBC_MAJOR}.${MIN_GLIBC_MINOR};" + warn "reinstall the statically linked build with SKILLKEEPER_LIBC=musl set" + fi + # Exit non-zero: a binary that cannot start is a failed install, and + # `curl ... | sh && next-step` must not proceed as though it worked. + exit 1 +fi diff --git a/scripts/release-downloads.mjs b/scripts/release-downloads.mjs index 5e2d6f2c..f08d36a3 100644 --- a/scripts/release-downloads.mjs +++ b/scripts/release-downloads.mjs @@ -46,14 +46,25 @@ if (!existsSync(distDir)) { const url = (name) => `https://github.com/${REPO}/releases/download/${encodeURIComponent(rawTag)}/${encodeURIComponent(name)}`; -/** Rust target triple -> human platform, for the CLI archives. */ +/** + * Rust target triple -> human platform, for the CLI archives. + * + * `note` disambiguates two archives that are otherwise the same platform. Linux + * ships two builds per architecture and the difference decides whether the + * binary starts at all, so it has to be on the label: the gnu one is linked + * against the release runner's glibc, the musl one is static and depends on no + * libc. Keep the stated glibc version in step with the runner image in + * .github/workflows/release.yml (the `check-glibc-floor` skill verifies it). + */ const CLI_TARGETS = { 'aarch64-apple-darwin': { os: 'macOS', arch: 'Apple Silicon', rank: 1 }, 'x86_64-apple-darwin': { os: 'macOS', arch: 'Intel', rank: 2 }, 'x86_64-pc-windows-msvc': { os: 'Windows', arch: 'x64', rank: 3 }, 'aarch64-pc-windows-msvc': { os: 'Windows', arch: 'arm64', rank: 4 }, - 'x86_64-unknown-linux-gnu': { os: 'Linux', arch: 'x64', rank: 5 }, - 'aarch64-unknown-linux-gnu': { os: 'Linux', arch: 'arm64', rank: 6 }, + 'x86_64-unknown-linux-gnu': { os: 'Linux', arch: 'x64', rank: 5, note: 'glibc 2.35+' }, + 'aarch64-unknown-linux-gnu': { os: 'Linux', arch: 'arm64', rank: 6, note: 'glibc 2.35+' }, + 'x86_64-unknown-linux-musl': { os: 'Linux', arch: 'x64', rank: 7, note: 'static, musl' }, + 'aarch64-unknown-linux-musl': { os: 'Linux', arch: 'arm64', rank: 8, note: 'static, musl' }, }; /** Desktop bundle extension -> the format shown in the label. */ @@ -104,7 +115,7 @@ for (const name of readdirSync(distDir).sort()) { if (known) { cli.push({ name, - label: `CLI ${known.os} ${known.arch}`, + label: known.note ? `CLI ${known.os} ${known.arch} (${known.note})` : `CLI ${known.os} ${known.arch}`, rank: known.rank, }); } else { @@ -150,7 +161,10 @@ if (cli.length > 0) { '```\n\n' + '```powershell\n' + `irm https://raw.githubusercontent.com/${REPO}/main/scripts/install.ps1 | iex\n` + - '```', + '```\n\n' + + 'On Linux the script reads the host glibc and picks between the gnu and ' + + 'musl builds on its own; choose between them by hand only for a manual ' + + 'download.', ); }