From 861804ddecca3651a426418b55cfe7259703ee7c Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Mon, 24 Aug 2026 12:46:01 +0530 Subject: [PATCH] feat(packaging): publish the CLI to npm and PyPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homebrew, Scoop, deb and rpm already ship; npm and PyPI are where most agents and developers look first, and goreleaser publishes to neither. Both are built here from the binaries goreleaser has already produced, so every channel ships the same bytes for a tag. Names are `modelslab-cli` on both registries. `modelslab` is taken on each by the respective SDK; both packages still register the `modelslab` command. npm: an entry package plus one package per platform - `modelslab-cli` contains only a launcher shim and declares the six platform packages as optionalDependencies. npm installs the one matching os/cpu. - The obvious alternative, a postinstall that downloads the binary, was rejected: it needs network at install time and produces a silently broken install under `npm ci --ignore-scripts`, which plenty of CI and agent sandboxes set. - The shim resolves the binary three ways. A plain require.resolve covers a normal global install and nothing else — it fails through any symlink, which means `npm install `, `npm link`, and every pnpm install. Found by installing the built package rather than by reading the code. PyPI: one wheel per platform - Each wheel carries the binary and a console script that execv's it. Wheels are written directly rather than through a build backend; nothing is compiled, so a backend would only add a dependency and hide the platform tag. - Two bugs that static checks do not catch, both found by installing and running: - pip decides executability with `stat.S_ISREG(mode) and mode & 0o111`, so the zip entry needs the file-TYPE bits. A bare 0o755 fails S_ISREG, the binary unpacks 0644, and the first run dies with EPERM. `twine check` passes it. - Tags are semver, wheel filenames are PEP 440. `v1.2.3-rc1` naively yields `modelslab_cli-1.2.3-rc1-py3-none-*.whl`, which pip reads as version 1.2.3 with build tag `rc1` — build tags must start with a digit, so the file is invalid. Tags are normalised to `1.2.3rc1`. Release and CI - release.yml builds and publishes both on tag, guarded on the token being configured so a missing secret skips that registry instead of failing the release. `secrets` is not an available context in a step-level `if`, so the tokens are mapped to env and the guard reads that. - Binaries are collected from goreleaser's artifacts.json rather than by parsing dist/ directory names: those carry microarchitecture suffixes (_v1, _v8.0) that differ per target and move between goreleaser versions. - ci.yml builds both packages on every PR and then installs and RUNS them. Both failure modes above pass every static check, so the only test that means anything is executing the result. Verified locally against a real `goreleaser build --snapshot`: 6 binaries collected, 7 npm packages and 6 wheels built, all wheels pass twine check, and both an npm install and a wheel install produce a working `modelslab --version`. Publishing needs NPM_TOKEN and PYPI_TOKEN in repository secrets; until they are set the new steps no-op. --- .github/workflows/ci.yml | 65 +++++++++ .github/workflows/release.yml | 62 +++++++++ .gitignore | 4 + README.md | 20 +++ packaging/README.md | 78 +++++++++++ packaging/npm/README.md | 20 +++ packaging/npm/build.mjs | 151 ++++++++++++++++++++ packaging/npm/shim.cjs | 124 +++++++++++++++++ packaging/pypi/README.md | 20 +++ packaging/pypi/build.py | 251 ++++++++++++++++++++++++++++++++++ 10 files changed, 795 insertions(+) create mode 100644 packaging/README.md create mode 100644 packaging/npm/README.md create mode 100644 packaging/npm/build.mjs create mode 100644 packaging/npm/shim.cjs create mode 100644 packaging/pypi/README.md create mode 100644 packaging/pypi/build.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eff4cd..5790bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,71 @@ jobs: - name: Integration Tests (no auth) run: go test ./tests/ -v -count=1 -timeout 120s + # The npm and PyPI packagers consume goreleaser's output. Nothing else in CI + # exercises them, so without this a packaging break is only discovered by a + # tag push — i.e. by a broken release. + packaging: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: build --snapshot --clean + + - name: Collect binaries + run: | + set -euo pipefail + jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \ + dist/artifacts.json | + while IFS=$'\t' read -r target path; do + mkdir -p "artifacts/$target" + cp "$path" "artifacts/$target/" + done + test "$(find artifacts -type f | wc -l)" -eq 6 + + - name: Build npm packages + run: node packaging/npm/build.mjs v0.0.0 artifacts dist/npm + + - name: Install and run the npm package + run: | + set -euo pipefail + mkdir -p /tmp/npmcheck && cd /tmp/npmcheck && npm init -y >/dev/null + npm install --no-audit --no-fund \ + "$GITHUB_WORKSPACE/dist/npm/modelslab-cli-linux-x64" \ + "$GITHUB_WORKSPACE/dist/npm/modelslab-cli" + # The real check: the shim resolves the binary and the binary runs. + ./node_modules/.bin/modelslab --version + + - name: Build PyPI wheels + run: python3 packaging/pypi/build.py v0.0.0 artifacts dist/pypi + + - name: Install and run the wheel + run: | + set -euo pipefail + python3 -m pip install --quiet twine + python3 -m twine check dist/pypi/*.whl + python3 -m pip install --quiet \ + dist/pypi/modelslab_cli-0.0.0-py3-none-manylinux2014_x86_64.whl + # Catches the executable-bit bug: twine check passes a wheel whose + # binary unpacks 0644, and only running it fails. + modelslab --version + build-matrix: runs-on: ubuntu-latest needs: test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 507bcd5..5e5e74c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,3 +43,65 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} + + # Both registries package the SAME binaries goreleaser just built, taken + # from dist/ rather than rebuilt, so npm, PyPI, Homebrew and Scoop can + # never ship different bytes for one tag. + - name: Collect release binaries + run: | + set -euo pipefail + # Read goreleaser's own manifest rather than parsing dist/ directory + # names: those carry microarchitecture suffixes (_v1, _v8.0) that move + # between goreleaser versions, and artifacts.json states goos/goarch + # outright. + jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \ + dist/artifacts.json | + while IFS=$'\t' read -r target path; do + mkdir -p "artifacts/$target" + cp "$path" "artifacts/$target/" + done + find artifacts -type f | sort + test "$(find artifacts -type f | wc -l)" -eq 6 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Build npm packages + run: node packaging/npm/build.mjs "${GITHUB_REF_NAME}" artifacts dist/npm + + # `secrets` is not an available context in a step-level `if`, so the token + # is mapped to env and the guard reads that. Without the guard, a fork or a + # repo that has not configured the token fails the whole release. + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + if: ${{ env.NODE_AUTH_TOKEN != '' }} + run: | + set -euo pipefail + # Platform packages first: the entry package depends on them, and an + # entry published against versions that do not exist yet is an install + # that fails for everyone until the next step lands. + for pkg in dist/npm/modelslab-cli-*; do + npm publish "$pkg" --access public --provenance + done + npm publish dist/npm/modelslab-cli --access public --provenance + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build PyPI wheels + run: python3 packaging/pypi/build.py "${GITHUB_REF_NAME}" artifacts dist/pypi + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + if: ${{ env.TWINE_PASSWORD != '' }} + run: | + set -euo pipefail + python3 -m pip install --quiet twine + python3 -m twine check dist/pypi/*.whl + python3 -m twine upload dist/pypi/*.whl diff --git a/.gitignore b/.gitignore index a6b98f4..d3a7739 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ go.work.sum # GoReleaser dist/ +# Binaries collected from a release for the npm/PyPI packagers +artifacts/ + # IDE .idea/ .vscode/ @@ -35,3 +38,4 @@ Thumbs.db # Generated output generated/ +__pycache__/ diff --git a/README.md b/README.md index 8d1ab82..41edad5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,26 @@ The official command-line interface for [ModelsLab](https://modelslab.com) — m ## Installation +### npm + +```bash +npm install -g modelslab-cli +``` + +Ships the prebuilt binary for your platform as an optional dependency — nothing +is compiled and no install script runs. + +### PyPI + +```bash +pip install modelslab-cli +``` + +Platform wheels; no build step and no Python dependencies. + +Both register the `modelslab` command. The `modelslab` packages on npm and +PyPI are the **SDKs**, not this CLI — hence the `-cli` suffix. + ### macOS (Homebrew) ```bash diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..5112403 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,78 @@ +# Packaging + +The CLI is a single Go binary. Homebrew, Scoop, deb and rpm are produced by +goreleaser directly; npm and PyPI are not registries goreleaser publishes to, so +they are built here from the binaries goreleaser has already produced. + +Nothing in this directory compiles anything. Both builders take a directory of +extracted release binaries and repackage them, so every channel ships the same +bytes for a given tag. + +``` +artifacts/ + darwin_amd64/modelslab + darwin_arm64/modelslab + linux_amd64/modelslab + linux_arm64/modelslab + windows_amd64/modelslab.exe + windows_arm64/modelslab.exe +``` + +## npm — `packaging/npm/build.mjs` + +```bash +node packaging/npm/build.mjs v1.2.3 artifacts dist/npm +``` + +Produces seven packages: one entry package (`modelslab-cli`) whose only content +is a launcher shim, plus one package per platform holding just the binary and +the `os`/`cpu` fields npm filters on. The entry package declares the six as +`optionalDependencies`, so npm installs exactly the one that matches. + +The alternative — one package with a postinstall that downloads a binary — was +rejected deliberately. It needs network at install time and produces a silently +broken install under `npm ci --ignore-scripts`, which many CI and agent sandboxes +set. Six small packages buy an install that cannot half-work. + +**Publish platform packages before the entry package.** The entry package pins +exact versions of all six; publishing it first leaves a window where every +install fails. + +## PyPI — `packaging/pypi/build.py` + +```bash +python3 packaging/pypi/build.py v1.2.3 artifacts dist/pypi +``` + +Produces one wheel per platform, each containing the binary and a console script +that `execv`s it. Wheels are written directly rather than through a build +backend: there is nothing to compile, so a backend would only add a dependency, +and writing them here keeps the platform tag explicit instead of inferred from +whatever host ran the build. + +Two things that are easy to get wrong and are covered by CI: + +- **Version normalisation.** Tags are semver, wheel filenames are PEP 440. A + `v1.2.3-rc1` tag naively becomes `modelslab_cli-1.2.3-rc1-py3-none-*.whl`, + which pip reads as version `1.2.3` with build tag `rc1` — and build tags must + start with a digit, so the file is invalid. `normalise_version()` converts it + to `1.2.3rc1`. +- **The executable bit.** pip decides whether to mark an unpacked file executable + with `stat.S_ISREG(mode) and mode & 0o111`, so the zip entry's mode has to + carry the file-type bits, not just permissions. A bare `0o755` fails `S_ISREG`, + the binary lands `0o644`, and the first run dies with `EPERM`. `twine check` + passes either way; only installing and running catches it. + +## Releasing + +`.github/workflows/release.yml` runs both builders on a tag and publishes if the +corresponding token is configured. Missing tokens skip that registry rather than +failing the release. + +| Secret | Registry | +| --- | --- | +| `NPM_TOKEN` | npm (automation token with publish rights) | +| `PYPI_TOKEN` | PyPI (project or account API token, used as `__token__`) | + +`.github/workflows/ci.yml` builds both on every PR and installs and *runs* the +result, because both of the failure modes above pass every static check. diff --git a/packaging/npm/README.md b/packaging/npm/README.md new file mode 100644 index 0000000..a64d7c9 --- /dev/null +++ b/packaging/npm/README.md @@ -0,0 +1,20 @@ +# ModelsLab CLI + +AI generation and account management from the terminal. One command surface over +the ModelsLab API: image, video, audio, 3D and LLM generation, plus authentication, +billing, wallet, subscriptions and model discovery. + +```bash +npm install -g modelslab-cli +modelslab auth login +modelslab generate image --prompt "a lighthouse at dusk" --model flux +``` + +The package installs a prebuilt binary for your platform as an optional +dependency — nothing is compiled and no install script runs. + +Supported: macOS (Intel, Apple Silicon), Linux (x64, arm64), Windows (x64, arm64). + +- Docs: https://docs.modelslab.com +- Source: https://github.com/ModelsLab/modelslab-cli +- Other install methods (Homebrew, Scoop, shell): https://modelslab.sh diff --git a/packaging/npm/build.mjs b/packaging/npm/build.mjs new file mode 100644 index 0000000..be50c1b --- /dev/null +++ b/packaging/npm/build.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * Builds the npm packages for a released version. + * + * Layout follows the esbuild/biome pattern: one entry package that declares an + * optionalDependency on a package per platform, each containing nothing but the + * binary and `os`/`cpu` fields. npm installs exactly the one that matches and + * skips the rest. + * + * The obvious alternative — one package with a postinstall script that downloads + * the right binary — was rejected on purpose. It needs network at install time + * and silently produces a broken install under `npm ci --ignore-scripts`, which + * plenty of CI and agent sandboxes set. Six small packages are the cost of an + * install that cannot half-work. + * + * Usage: node packaging/npm/build.mjs [out-dir] + * holds the goreleaser tarballs already extracted into + * /_/modelslab[.exe] + */ +import { mkdirSync, writeFileSync, copyFileSync, existsSync, chmodSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const NAME = 'modelslab-cli'; +const BIN = 'modelslab'; +const REPO = 'https://github.com/ModelsLab/modelslab-cli'; +const DESCRIPTION = + 'ModelsLab CLI — AI generation and account management from the terminal'; + +/** goreleaser target -> npm os/cpu. Windows is `win32` to npm, whatever Go calls it. */ +const TARGETS = [ + { go: 'darwin_amd64', os: 'darwin', cpu: 'x64' }, + { go: 'darwin_arm64', os: 'darwin', cpu: 'arm64' }, + { go: 'linux_amd64', os: 'linux', cpu: 'x64' }, + { go: 'linux_arm64', os: 'linux', cpu: 'arm64' }, + { go: 'windows_amd64', os: 'win32', cpu: 'x64' }, + { go: 'windows_arm64', os: 'win32', cpu: 'arm64' }, +]; + +const [, , rawVersion, artifactsDir, outDirArg] = process.argv; + +if (!rawVersion || !artifactsDir) { + console.error('usage: build.mjs [out-dir]'); + process.exit(1); +} + +// Tags are v-prefixed; npm versions are not. +const version = rawVersion.replace(/^v/, ''); +const outDir = resolve(outDirArg ?? 'dist/npm'); + +if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { + console.error(`refusing to build: "${version}" is not a semver version`); + process.exit(1); +} + +const common = { + version, + description: DESCRIPTION, + homepage: 'https://modelslab.sh', + repository: { type: 'git', url: `git+${REPO}.git` }, + bugs: { url: `${REPO}/issues` }, + license: 'MIT', + author: 'ModelsLab ', +}; + +const platformPackages = []; + +for (const target of TARGETS) { + const exe = target.os === 'win32' ? `${BIN}.exe` : BIN; + const source = join(artifactsDir, target.go, exe); + + if (!existsSync(source)) { + console.error(`missing binary for ${target.go}: ${source}`); + process.exit(1); + } + + const pkgName = `${NAME}-${target.os}-${target.cpu}`; + const pkgDir = join(outDir, pkgName); + mkdirSync(join(pkgDir, 'bin'), { recursive: true }); + + const dest = join(pkgDir, 'bin', exe); + copyFileSync(source, dest); + // npm preserves the mode in the tarball; without this the shim cannot exec it. + chmodSync(dest, 0o755); + + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify( + { + name: pkgName, + ...common, + description: `${DESCRIPTION} (${target.os} ${target.cpu} binary)`, + os: [target.os], + cpu: [target.cpu], + // Only the binary. No lifecycle scripts, nothing to execute at install. + files: ['bin'], + preferUnplugged: true, + }, + null, + 2 + ) + '\n' + ); + + platformPackages.push(pkgName); + console.log(`built ${pkgName}`); +} + +// --- entry package ----------------------------------------------------------- +const entryDir = join(outDir, NAME); +mkdirSync(join(entryDir, 'bin'), { recursive: true }); + +writeFileSync( + join(entryDir, 'package.json'), + JSON.stringify( + { + name: NAME, + ...common, + keywords: [ + 'modelslab', + 'cli', + 'ai', + 'image-generation', + 'video-generation', + 'text-to-speech', + 'llm', + 'agent', + ], + bin: { [BIN]: 'bin/modelslab.cjs' }, + files: ['bin', 'README.md'], + // Optional so an unsupported platform fails at run time with a + // readable message instead of failing the whole install. + optionalDependencies: Object.fromEntries( + platformPackages.map((name) => [name, version]) + ), + engines: { node: '>=16' }, + }, + null, + 2 + ) + '\n' +); + +copyFileSync( + resolve(import.meta.dirname, 'shim.cjs'), + join(entryDir, 'bin', 'modelslab.cjs') +); +copyFileSync( + resolve(import.meta.dirname, 'README.md'), + join(entryDir, 'README.md') +); + +console.log(`built ${NAME} (entry) with ${platformPackages.length} optional deps`); +console.log(`output: ${outDir}`); diff --git a/packaging/npm/shim.cjs b/packaging/npm/shim.cjs new file mode 100644 index 0000000..8f4790c --- /dev/null +++ b/packaging/npm/shim.cjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Locates the platform binary and hands the process over to it. + * + * `spawnSync` with `stdio: 'inherit'` rather than an exec: Node has no execv, and + * inheriting the streams keeps interactive prompts, pipes and TTY detection + * behaving as if the binary were invoked directly. The child's exit code and + * terminating signal are both propagated — a wrapper that swallowed a non-zero + * exit would break every script that checks it. + */ +const { spawnSync } = require('node:child_process'); +const { existsSync, realpathSync } = require('node:fs'); +const { join, dirname, sep } = require('node:path'); + +const PLATFORM_PACKAGES = { + 'darwin x64': 'modelslab-cli-darwin-x64', + 'darwin arm64': 'modelslab-cli-darwin-arm64', + 'linux x64': 'modelslab-cli-linux-x64', + 'linux arm64': 'modelslab-cli-linux-arm64', + 'win32 x64': 'modelslab-cli-win32-x64', + 'win32 arm64': 'modelslab-cli-win32-arm64', +}; + +function resolveBinary() { + const key = `${process.platform} ${process.arch}`; + const pkg = PLATFORM_PACKAGES[key]; + + if (!pkg) { + throw new Error( + `ModelsLab CLI does not ship a binary for ${key}.\n` + + `Supported: ${Object.keys(PLATFORM_PACKAGES).join(', ')}.\n` + + `Build from source: https://github.com/ModelsLab/modelslab-cli` + ); + } + + const exe = process.platform === 'win32' ? 'modelslab.exe' : 'modelslab'; + const subpath = `${pkg}/bin/${exe}`; + + /* + * Three lookups, because one is not enough in practice. + * + * The plain resolve covers a normal `npm i -g`. It fails whenever this + * package is reached through a symlink — `npm install `, `npm link`, + * and every pnpm install — because module resolution then starts from the + * link target, which has no node_modules of its own. Anchoring the search at + * the real directory and at the caller's cwd covers those. + */ + const anchors = [__dirname]; + try { + anchors.push(realpathSync(__dirname)); + } catch { + // realpath can fail on a broken link; the other anchors still apply. + } + anchors.push(process.cwd()); + + for (const resolver of [ + () => require.resolve(subpath), + () => require.resolve(subpath, { paths: anchors }), + ]) { + try { + return resolver(); + } catch { + // try the next strategy + } + } + + /* + * Last resort: walk up from here looking for a sibling inside any + * node_modules directory. Catches layouts where the package is present but + * unreachable by Node's algorithm from where this file physically lives. + */ + for (const anchor of anchors) { + let dir = anchor; + while (true) { + const candidate = join(dir, 'node_modules', pkg, 'bin', exe); + if (existsSync(candidate)) { + return candidate; + } + const parent = dirname(dir); + if (parent === dir || !parent.includes(sep)) { + break; + } + dir = parent; + } + } + + { + /* + * The optional dependency is missing. Almost always one of: + * `--no-optional`, an npm version that skipped it, or an install that + * partially failed. Say which package so the fix is one command. + */ + throw new Error( + `ModelsLab CLI is installed but the binary for ${key} is not.\n` + + `Expected the optional dependency "${pkg}".\n\n` + + `Fix it with: npm install ${pkg}\n` + + `Or reinstall: npm install -g modelslab-cli\n\n` + + `If you installed with --no-optional or --ignore-optional, that is why.` + ); + } +} + +let binary; +try { + binary = resolveBinary(); +} catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' }); + +if (result.error) { + process.stderr.write(`failed to run ModelsLab CLI: ${result.error.message}\n`); + process.exit(1); +} + +// A signalled child has a null status; re-raise so the parent shell sees the +// same thing it would have seen running the binary directly. +if (result.signal) { + process.kill(process.pid, result.signal); +} + +process.exit(result.status ?? 1); diff --git a/packaging/pypi/README.md b/packaging/pypi/README.md new file mode 100644 index 0000000..a64d7c9 --- /dev/null +++ b/packaging/pypi/README.md @@ -0,0 +1,20 @@ +# ModelsLab CLI + +AI generation and account management from the terminal. One command surface over +the ModelsLab API: image, video, audio, 3D and LLM generation, plus authentication, +billing, wallet, subscriptions and model discovery. + +```bash +npm install -g modelslab-cli +modelslab auth login +modelslab generate image --prompt "a lighthouse at dusk" --model flux +``` + +The package installs a prebuilt binary for your platform as an optional +dependency — nothing is compiled and no install script runs. + +Supported: macOS (Intel, Apple Silicon), Linux (x64, arm64), Windows (x64, arm64). + +- Docs: https://docs.modelslab.com +- Source: https://github.com/ModelsLab/modelslab-cli +- Other install methods (Homebrew, Scoop, shell): https://modelslab.sh diff --git a/packaging/pypi/build.py b/packaging/pypi/build.py new file mode 100644 index 0000000..2d0e6ab --- /dev/null +++ b/packaging/pypi/build.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Build platform wheels for the ModelsLab CLI. + +One wheel per platform, each containing the prebuilt Go binary and a console +script that hands the process over to it. + +Wheels are written directly rather than through setuptools. The binary is not +Python and there is nothing to compile, so a build backend would only add a +dependency and a temp directory to the same zip file. The wheel format is +specified well enough (PEP 427, PEP 425) to emit correctly, and doing it here +keeps the platform tag explicit instead of inferred from the build host. + +Usage: python3 packaging/pypi/build.py [out-dir] + holds the goreleaser tarballs already extracted into + /_/modelslab[.exe] +""" +from __future__ import annotations + +import base64 +import csv +import hashlib +import io +import os +import re +import stat +import sys +import zipfile +from pathlib import Path + +NAME = "modelslab-cli" +DIST = "modelslab_cli" +SUMMARY = "ModelsLab CLI — AI generation and account management from the terminal" + +# goreleaser target -> PEP 425 platform tag. +# +# manylinux2014 (glibc 2.17, CentOS 7) rather than a newer baseline: the binary +# is CGO_ENABLED=0, so it has no libc dependency at all and the tag only needs to +# be old enough that pip on any live distro will accept it. +TARGETS = [ + ("darwin_amd64", "macosx_10_12_x86_64"), + ("darwin_arm64", "macosx_11_0_arm64"), + ("linux_amd64", "manylinux2014_x86_64"), + ("linux_arm64", "manylinux2014_aarch64"), + ("windows_amd64", "win_amd64"), + ("windows_arm64", "win_arm64"), +] + +LAUNCHER = '''"""Console entry point for the ModelsLab CLI.""" +import os +import sys +from pathlib import Path + + +def _binary() -> Path: + exe = "modelslab.exe" if os.name == "nt" else "modelslab" + return Path(__file__).resolve().parent / "bin" / exe + + +def main() -> int: + binary = _binary() + + if not binary.exists(): + sys.stderr.write( + "ModelsLab CLI binary is missing from the installed package.\\n" + "Reinstall with: pip install --force-reinstall modelslab-cli\\n" + ) + return 1 + + argv = [str(binary), *sys.argv[1:]] + + if os.name == "nt": + # Windows has no exec that replaces the process in a way cmd respects; + # spawn and forward the exit code instead. + import subprocess + + return subprocess.call(argv) + + # Replace this process so signals, exit codes and TTY behaviour are the + # binary's own. A subprocess wrapper here would swallow SIGINT handling. + os.execv(str(binary), argv) + return 1 # unreachable; execv does not return + + +if __name__ == "__main__": + raise SystemExit(main()) +''' + + +def _read_description() -> str: + readme = Path(__file__).resolve().parent / "README.md" + return readme.read_text(encoding="utf-8") if readme.is_file() else SUMMARY + + +def _metadata(version: str) -> str: + return "\n".join( + [ + "Metadata-Version: 2.1", + f"Name: {NAME}", + f"Version: {version}", + f"Summary: {SUMMARY}", + "Home-page: https://modelslab.sh", + "Author: ModelsLab", + "Author-email: support@modelslab.com", + "License: MIT", + "Project-URL: Source, https://github.com/ModelsLab/modelslab-cli", + "Project-URL: Documentation, https://docs.modelslab.com", + "Classifier: Development Status :: 4 - Beta", + "Classifier: Environment :: Console", + "Classifier: Intended Audience :: Developers", + "Classifier: License :: OSI Approved :: MIT License", + "Classifier: Programming Language :: Go", + "Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence", + "Requires-Python: >=3.8", + "Description-Content-Type: text/markdown", + "", + _read_description(), + ] + ) + + +def _wheel_tag(platform_tag: str) -> str: + # py3-none-: pure launcher, no Python ABI, platform-specific payload. + return f"py3-none-{platform_tag}" + + +def _urlsafe_digest(payload: bytes) -> str: + digest = hashlib.sha256(payload).digest() + return "sha256=" + base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +def build_wheel(version: str, artifacts: Path, out_dir: Path, go_target: str, platform_tag: str) -> Path: + exe = "modelslab.exe" if go_target.startswith("windows") else "modelslab" + source = artifacts / go_target / exe + + if not source.is_file(): + raise SystemExit(f"missing binary for {go_target}: {source}") + + tag = _wheel_tag(platform_tag) + dist_info = f"{DIST}-{version}.dist-info" + out_dir.mkdir(parents=True, exist_ok=True) + wheel_path = out_dir / f"{DIST}-{version}-{tag}.whl" + + records: list[tuple[str, str, int]] = [] + + def add(zf: zipfile.ZipFile, arcname: str, payload: bytes, *, executable: bool = False) -> None: + info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0)) + # The mode must carry the file-TYPE bits, not just the permission bits. + # pip decides whether to make an unpacked file executable with + # `stat.S_ISREG(mode) and mode & 0o111`, so a bare 0o755 fails S_ISREG, + # the binary lands as 0o644, and execv dies with EPERM at first run. + # twine check does not catch this — only installing and running does. + mode = stat.S_IFREG | (0o755 if executable else 0o644) + info.external_attr = (mode << 16) | 0o600 + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, payload) + records.append((arcname, _urlsafe_digest(payload), len(payload))) + + with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as zf: + add(zf, f"{DIST}/__init__.py", b'"""ModelsLab CLI."""\n') + add(zf, f"{DIST}/__main__.py", LAUNCHER.encode("utf-8")) + add(zf, f"{DIST}/bin/{exe}", source.read_bytes(), executable=True) + + add( + zf, + f"{dist_info}/METADATA", + _metadata(version).encode("utf-8"), + ) + add( + zf, + f"{dist_info}/WHEEL", + ( + "Wheel-Version: 1.0\n" + "Generator: modelslab-cli packaging/pypi/build.py\n" + "Root-Is-Purelib: false\n" + f"Tag: {tag}\n" + ).encode("utf-8"), + ) + add( + zf, + f"{dist_info}/entry_points.txt", + f"[console_scripts]\nmodelslab = {DIST}.__main__:main\n".encode("utf-8"), + ) + add(zf, f"{dist_info}/top_level.txt", f"{DIST}\n".encode("utf-8")) + + # RECORD lists every file including itself, with its own hash left blank. + buffer = io.StringIO() + writer = csv.writer(buffer, lineterminator="\n") + for arcname, digest, size in records: + writer.writerow([arcname, digest, size]) + writer.writerow([f"{dist_info}/RECORD", "", ""]) + + record_info = zipfile.ZipInfo(f"{dist_info}/RECORD", date_time=(1980, 1, 1, 0, 0, 0)) + record_info.external_attr = ((stat.S_IFREG | 0o644) << 16) | 0o600 + record_info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(record_info, buffer.getvalue()) + + return wheel_path + + +def normalise_version(raw: str) -> str: + """Turn a git tag into a PEP 440 version. + + Tags are semver (`v1.2.3`, `v1.2.3-rc1`); PEP 440 is not. The hyphen matters + more than it looks: it is the field separator in a wheel filename, so + `1.2.3-rc1` produces `modelslab_cli-1.2.3-rc1-py3-none-*.whl`, which pip + parses as version `1.2.3` with a build tag of `rc1` — and build tags must + start with a digit, so the file is simply invalid. + """ + version = raw.lstrip("v").strip() + + match = re.fullmatch( + r"(\d+\.\d+\.\d+)" + r"(?:[-.]?(a|b|rc|alpha|beta)\.?(\d+))?", + version, + re.IGNORECASE, + ) + + if not match: + raise SystemExit( + f'refusing to build: "{raw}" is not a version this can express in PEP 440' + ) + + release, phase, number = match.groups() + + if not phase: + return release + + canonical = {"alpha": "a", "beta": "b", "a": "a", "b": "b", "rc": "rc"}[phase.lower()] + + return f"{release}{canonical}{number}" + + +def main() -> int: + if len(sys.argv) < 3: + sys.stderr.write("usage: build.py [out-dir]\n") + return 1 + + version = normalise_version(sys.argv[1]) + artifacts = Path(sys.argv[2]) + out_dir = Path(sys.argv[3]) if len(sys.argv) > 3 else Path("dist/pypi") + + for go_target, platform_tag in TARGETS: + path = build_wheel(version, artifacts, out_dir, go_target, platform_tag) + print(f"built {path.name}") + + print(f"output: {out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())