mirror of
https://github.com/cloudstack-llc/mlx-knife.git
synced 2026-07-19 22:53:30 -04:00
patches
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version (e.g., 2.0.0b6)"
|
||||
required: true
|
||||
variant:
|
||||
description: "Build variant"
|
||||
required: false
|
||||
default: external
|
||||
type: choice
|
||||
options: [external, pyinstaller]
|
||||
cpython_url:
|
||||
description: "Standalone CPython URL (python-build-standalone)"
|
||||
required: false
|
||||
default: ""
|
||||
r2_path:
|
||||
description: "R2 path prefix (default: releases/<version>/)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
build-sign-notarize:
|
||||
runs-on: macos-14
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
SIGN_ID: ${{ secrets.CSC_NAME }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve CPython (external variant)
|
||||
if: ${{ inputs.variant == 'external' }}
|
||||
run: |
|
||||
if [[ -z "${{ inputs.cpython_url }}" ]]; then
|
||||
if [[ ! -x python/bin/python3 && ! -x python/bin/python3.10 && ! -x python/bin/python3.12 ]]; then
|
||||
echo "No embedded python present; provide cpython_url input or commit ./python/bin/python3" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Import Developer ID certificate (if provided)
|
||||
if: env.SIGN_ID != '' && secrets.MACOS_CERT_P12 != ''
|
||||
env:
|
||||
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
|
||||
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN=build.keychain
|
||||
security create-keychain -p "" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "" "$KEYCHAIN"
|
||||
echo "$MACOS_CERT_P12" | base64 --decode > cert.p12
|
||||
security import cert.p12 -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security list-keychain -d user -s "$KEYCHAIN" login.keychain-db
|
||||
rm -f cert.p12
|
||||
|
||||
- name: Release build (external)
|
||||
if: ${{ inputs.variant == 'external' }}
|
||||
run: |
|
||||
chmod +x scripts/*.sh || true
|
||||
if [[ -n "${{ inputs.cpython_url }}" ]]; then
|
||||
CPYTHON_URL="${{ inputs.cpython_url }}" scripts/release.sh --variant external --disable-libval
|
||||
else
|
||||
scripts/release.sh --variant external --embedded-python "$PWD/python/bin/python3" --disable-libval
|
||||
fi
|
||||
|
||||
- name: Release build (pyinstaller)
|
||||
if: ${{ inputs.variant == 'pyinstaller' }}
|
||||
run: |
|
||||
chmod +x scripts/*.sh || true
|
||||
scripts/release.sh --variant pyinstaller --disable-libval
|
||||
|
||||
- name: Upload artifacts (tgz only)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-artifacts-${{ inputs.version }}
|
||||
path: |
|
||||
artifacts/*.tgz
|
||||
|
||||
- name: Upload to R2 (tgz)
|
||||
if: ${{ secrets.R2_ACCESS_KEY_ID != '' }}
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_PATH_INPUT: ${{ inputs.r2_path }}
|
||||
run: |
|
||||
TGZ=$(ls -1 artifacts/*.tgz | tail -n1)
|
||||
if [[ -z "$TGZ" ]]; then echo "No tgz found" >&2; exit 1; fi
|
||||
pipx install awscli || python3 -m pip install --user awscli
|
||||
DEST_PATH="${R2_PATH_INPUT:-releases/${{ inputs.version }}/}"
|
||||
aws s3 cp "$TGZ" "s3://$R2_BUCKET/${DEST_PATH}" --endpoint-url "$R2_ENDPOINT"
|
||||
@@ -39,3 +39,11 @@ benchmarks/reports/2026-0[12]*.jsonl
|
||||
# Benchmark reports (ADR-013 Phase 0)
|
||||
# These reports ARE tracked in git for historical data
|
||||
#!benchmarks/reports/*.jsonl
|
||||
.pack-tmp
|
||||
.pyi-spec
|
||||
.venv
|
||||
.venv-build
|
||||
artifacts
|
||||
python
|
||||
dist-ext-python
|
||||
package-mac-signed.sh
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# External Python Mode (Electron-embedded Python)
|
||||
|
||||
This variant skips PyInstaller. We vendor all Python dependencies (and `mlxk2` itself) into a folder and run the CLI with the Python interpreter you bundle inside the Electron app (`MyApp.app/Contents/Resources/python/bin/python3`).
|
||||
|
||||
## Build quick reference
|
||||
|
||||
```bash
|
||||
# Preferred: install using the embedded Python
|
||||
EMBEDDED_PYTHON="/path/to/MyApp.app/Contents/Resources/python/bin/python3" \
|
||||
scripts/build_external_python.sh
|
||||
|
||||
# Dev fallback (uses ./python/bin/python3 if present, else system python3)
|
||||
scripts/build_external_python.sh
|
||||
|
||||
# Provide custom MLX / MLX-LM wheels
|
||||
scripts/build_external_python.sh \
|
||||
--mlx-wheel scripts/mlx-custom.whl \
|
||||
--mlx-lm-wheel scripts/mlx-lm-custom.whl
|
||||
|
||||
# Wheels-only mode (no interpreter execution)
|
||||
TARGET_PY_VERSION=3.12 \
|
||||
VENDOR_VIA_WHEELS=1 \
|
||||
scripts/build_external_python.sh
|
||||
```
|
||||
|
||||
Output: `dist-ext-python/<BIN_NAME>/` (default `msty-mlx-studio/`), containing:
|
||||
|
||||
- `_vendor/` – vendored site-packages (deps + project)
|
||||
- `msty-mlx-studio` – launcher that runs `-m mlxk2.cli` with `PYTHONPATH=_vendor`
|
||||
|
||||
### Build options reference
|
||||
|
||||
- `EMBEDDED_PYTHON` – interpreter used for installs (falls back to `./python/bin/python3`, then system python3).
|
||||
- `MLX_WHEEL`, `MLX_LM_WHEEL`, `--mlx-wheel`, `--mlx-lm-wheel` – custom package wheels; defaults auto-detect `scripts/mlx-custom.whl` and `scripts/mlx-lm-custom.whl` if present.
|
||||
- `VERSION` – temporary override for `mlxk2.__version__` while bundling; README references adjust automatically.
|
||||
- `DIST_DIR`, `BIN_NAME` – output location/name (default `dist-ext-python/msty-mlx-studio`).
|
||||
- `CLEAN=1` – delete the output directory before writing.
|
||||
- `PIP_ARGS="..."` – extra pip flags (shared by install/download steps).
|
||||
- `VENDOR_VIA_WHEELS=1`, `TARGET_PY_VERSION`, `TARGET_PLATFORM`, `TARGET_ABI` – download wheels without executing the embedded interpreter.
|
||||
|
||||
### Wheels-only mode (Gatekeeper-friendly)
|
||||
|
||||
If macOS blocks your embedded Python during the build, set `VENDOR_VIA_WHEELS=1` and choose a compatible `TARGET_PY_VERSION`. The script uses the system python to `pip download` macOS arm64 wheels and unpacks them into `_vendor`. Ensure the chosen Python minor version matches your embedded interpreter so compiled wheels load correctly.
|
||||
|
||||
### Custom MLX / MLX-LM wheels
|
||||
|
||||
Provide custom builds to keep the MLX stack in sync:
|
||||
|
||||
```bash
|
||||
scripts/build_external_python.sh \
|
||||
--mlx-wheel /abs/path/mlx.whl \
|
||||
--mlx-lm-wheel /abs/path/mlx_lm.whl
|
||||
```
|
||||
|
||||
Both options can also be set via environment variables. The script filters `mlx` and `mlx-lm` out of `requirements.txt`, then installs your wheels last so they override PyPI pins in `_vendor`.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
After building, run the launcher with these flags to confirm what was bundled:
|
||||
|
||||
```bash
|
||||
./dist-ext-python/msty-mlx-studio/msty-mlx-studio --python-info # interpreter summary
|
||||
./dist-ext-python/msty-mlx-studio/msty-mlx-studio --mlx-info # mlx version + dist-info path
|
||||
./dist-ext-python/msty-mlx-studio/msty-mlx-studio --pkg-info mlx_lm
|
||||
./dist-ext-python/msty-mlx-studio/msty-mlx-studio --check-mlx-stack
|
||||
```
|
||||
|
||||
`--check-mlx-stack` imports both `mlx` and `mlx_lm` to ensure API compatibility—run this after swapping in custom wheels.
|
||||
|
||||
### Version override
|
||||
|
||||
Stamp the vendored package with a release number:
|
||||
|
||||
```bash
|
||||
VERSION=2.0.0b4 scripts/build_external_python.sh
|
||||
```
|
||||
|
||||
The script temporarily rewrites `mlxk2/__version__` and README references for the build and restores them afterward, so your working tree stays clean.
|
||||
|
||||
## Packaging into Electron
|
||||
|
||||
Place the output under app resources, for example:
|
||||
|
||||
```
|
||||
MyApp.app/Contents/Resources/
|
||||
python/bin/python3 # bundled interpreter
|
||||
mlxk2/
|
||||
_vendor/
|
||||
msty-mlx-studio # launcher
|
||||
```
|
||||
|
||||
### Option A – use the launcher
|
||||
|
||||
- Command: `join(process.resourcesPath, 'mlxk2', 'msty-mlx-studio')`
|
||||
- Env: set `RESOURCES_PATH=process.resourcesPath` (the launcher uses it to locate `python/bin/python3`).
|
||||
|
||||
```js
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
const launcher = join(process.resourcesPath, 'mlxk2', 'msty-mlx-studio');
|
||||
|
||||
export function startServer({ host = '127.0.0.1', port = 8000, maxTokens } = {}) {
|
||||
const args = ['serve', '--host', host, '--port', String(port)];
|
||||
if (maxTokens != null) args.push('--max-tokens', String(maxTokens));
|
||||
const env = { ...process.env, RESOURCES_PATH: process.resourcesPath };
|
||||
const child = spawn(launcher, args, { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
return child;
|
||||
}
|
||||
```
|
||||
|
||||
### Option B – call Python directly
|
||||
|
||||
- Python: `join(process.resourcesPath, 'python', 'bin', 'python3')`
|
||||
- Vendor: `join(process.resourcesPath, 'mlxk2', '_vendor')`
|
||||
- Env: `PYTHONPATH=<vendor>[:$PYTHONPATH]`, `PYTHONNOUSERSITE=1`
|
||||
|
||||
```js
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
export function startServer({ host = '127.0.0.1', port = 8000, maxTokens } = {}) {
|
||||
const pythonPath = join(process.resourcesPath, 'python', 'bin', 'python3');
|
||||
const vendor = join(process.resourcesPath, 'mlxk2', '_vendor');
|
||||
const args = ['-m', 'mlxk2.cli', 'serve', '--host', host, '--port', String(port)];
|
||||
if (maxTokens != null) args.push('--max-tokens', String(maxTokens));
|
||||
const env = { ...process.env, PYTHONNOUSERSITE: '1', PYTHONPATH: vendor };
|
||||
const child = spawn(pythonPath, args, { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
return child;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Ship a compatible macOS arm64 Python with required system libs.
|
||||
- For private/offline wheels, pass `PIP_ARGS="--no-index --find-links /path/to/wheels"`.
|
||||
- Clear Gatekeeper quarantine on the embedded Python during development with `scripts/clear_quarantine_python.sh /path/to/Resources/python`.
|
||||
- Sanity-check at runtime:
|
||||
`PYTHONPATH=<vendor> <embedded-python> -c "import mlxk2, fastapi, uvicorn, mlx, mlx_lm; print('ok')"`
|
||||
- Sign + notarize the final `.app` for distribution.
|
||||
@@ -0,0 +1,136 @@
|
||||
# MLX Knife – Electron Integration Guide (macOS arm64)
|
||||
|
||||
This guide covers how our Electron app embeds the upstream `mlxk2` CLI and how we download models ourselves (without `pull`).
|
||||
|
||||
- Build the CLI once with `scripts/build.sh` (see `docs/ELECTRON_PACKAGING.md`).
|
||||
- Extract the archive into `app.getPath('userData')/msty-mlx-studio` (or whatever `BIN_NAME` you built).
|
||||
- Clear quarantine (`xattr -dr com.apple.quarantine <folder>`) on first install.
|
||||
|
||||
## CLI usage (list/show/run/server)
|
||||
|
||||
Wrap the CLI in a helper so we can spawn it with consistent env vars:
|
||||
|
||||
```js
|
||||
// electron/main/mlxk.js
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
const binRoot = join(app.getPath('userData'), 'msty-mlx-studio');
|
||||
const exePath = join(binRoot, 'msty-mlx-studio');
|
||||
|
||||
function baseEnv(extra = {}) {
|
||||
return {
|
||||
...process.env,
|
||||
HF_HOME: join(app.getPath('userData'), 'hf'),
|
||||
TRANSFORMERS_NO_TORCH: '1',
|
||||
TRANSFORMERS_NO_TF: '1',
|
||||
TRANSFORMERS_NO_FLAX: '1',
|
||||
HF_HUB_DISABLE_TELEMETRY: '1',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function spawnMlxk(args, options = {}) {
|
||||
const { env = {}, stdio = ['ignore', 'inherit', 'inherit'] } = options;
|
||||
return spawn(exePath, args, { env: baseEnv(env), stdio });
|
||||
}
|
||||
```
|
||||
|
||||
You can then build helpers such as:
|
||||
|
||||
```js
|
||||
export function listModels({ all = false, verbose = false } = {}) {
|
||||
const args = ['list'];
|
||||
if (all) args.push('--all');
|
||||
if (verbose) args.push('--verbose');
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnMlxk(args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const output = [];
|
||||
child.stdout.on('data', (d) => output.push(d.toString()))
|
||||
child.stderr.on('data', (d) => output.push(d.toString()))
|
||||
child.on('exit', (code) => code === 0 ? resolve(output.join('')) : reject(code));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The same `spawnMlxk` wrapper works for `show`, `rm`, `run`, or starting the HTTP server (`serve`/`server`). The packaged binary runs the server in-process (single PID), so a single `SIGINT` stops it.
|
||||
|
||||
## Downloads handled in Node
|
||||
|
||||
Rather than calling the CLI `pull`, we download models from Hugging Face directly so we have full control over progress and caching. Use `MLXKModelDownloader` (see `apps/desktop/src/mlx/MLXKModelDownloader.ts`). It mirrors the existing `MLXModelDownloader` API:
|
||||
|
||||
```ts
|
||||
import { MLXKModelDownloader } from './MLXKModelDownloader';
|
||||
const downloader = new MLXKModelDownloader(app.getPath('userData'));
|
||||
|
||||
await downloader.downloadModel('mlx-community/Qwen3-4B-Instruct-2507-8bit', (progress) => {
|
||||
// progress.fileName, progress.percentage, progress.speed, etc.
|
||||
});
|
||||
```
|
||||
|
||||
The downloader writes into the Hugging Face cache layout:
|
||||
```
|
||||
HF_HOME/hub/models--<owner>--<name>/snapshots/<commit>/...
|
||||
HF_HOME/hub/models--<owner>--<name>/refs/main # contains <commit>
|
||||
```
|
||||
Once the files are in place, the CLI can list/run the model immediately.
|
||||
|
||||
### Cancellation
|
||||
`MLXKModelDownloader.cancelDownloadForModel(modelId)` stops in-flight downloads, removes partial files, and cleans up empty directories.
|
||||
|
||||
## Environment for CLI processes
|
||||
|
||||
If you spawn the launcher directly from Electron, set these env vars:
|
||||
|
||||
- `MLXK_PYTHON` – absolute path to embedded Python (e.g., `…/msty-mlx-studio/python/bin/python3`).
|
||||
- `RESOURCES_PATH` – bundle root (folder containing the launcher and `python/`).
|
||||
- `HF_HOME` – shared cache location for downloads and CLI usage.
|
||||
- `TRANSFORMERS_NO_TORCH=1`, `TRANSFORMERS_NO_TF=1`, `TRANSFORMERS_NO_FLAX=1` – faster start, no unwanted backend checks.
|
||||
- `HF_HUB_DISABLE_TELEMETRY=1` – avoid telemetry from Hugging Face Hub.
|
||||
|
||||
Note: the native launcher compiled by our release process sets `MLXK2_SUPERVISE=0` by default so uvicorn runs in‑process (no extra python supervisor). This improves stop behavior from Electron.
|
||||
|
||||
## Server quick start
|
||||
|
||||
```bash
|
||||
HF_HOME="/Users/<user>/.myapp/hf" \
|
||||
~/Library/Application\ Support/MyApp/msty-mlx-studio/msty-mlx-studio \
|
||||
serve --host 127.0.0.1 --port 8000 --max-tokens 4000
|
||||
```
|
||||
|
||||
Interact via the OpenAI-compatible endpoints:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8000/v1/chat/completions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "Phi-3-mini-4k-instruct-4bit", "messages": [{"role":"user","content":"Hello"}]}'
|
||||
```
|
||||
|
||||
## Recommended server lifecycle from Electron
|
||||
|
||||
```js
|
||||
// Start server
|
||||
export function startServer({ host = '127.0.0.1', port = 8000, maxTokens } = {}) {
|
||||
const args = ['serve', '--host', host, '--port', String(port)];
|
||||
if (maxTokens != null) args.push('--max-tokens', String(maxTokens));
|
||||
const child = spawnMlxk(args, { stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
return child; // caller holds this
|
||||
}
|
||||
|
||||
// Stop server
|
||||
export function stopServer(child) {
|
||||
if (!child || child.killed) return;
|
||||
try { child.kill('SIGINT'); } catch {}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The server runs in‑process in the packaged binary (no uvicorn supervisor). If `SIGINT` doesn’t stop it (rare), escalate to `SIGKILL`.
|
||||
- The CLI also supports `server` as an alias for `serve` for backward compatibility with v1.
|
||||
|
||||
Process model:
|
||||
- You should see `msty-mlx-studio` as the parent process and a single `python` child while the server is running.
|
||||
- Stopping the parent with `SIGINT`/`SIGTERM` stops the child promptly. A second signal escalates.
|
||||
|
||||
That’s the entire integration: package the CLI once, manage downloads yourself with `MLXKModelDownloader`, and spawn the CLI for listing/running or serving models.
|
||||
@@ -0,0 +1,186 @@
|
||||
# MLX Knife – Electron Packaging (macOS arm64)
|
||||
|
||||
This document explains how we package the upstream `mlxk2` CLI for use inside our Electron app. We ship a self-contained binary bundle and keep upstream code unmodified. Model downloads happen in Electron, so we do not rely on `pull` at runtime.
|
||||
|
||||
## Overview
|
||||
|
||||
- Target: macOS Apple Silicon (M1/M2/M3), arm64.
|
||||
- Builder: PyInstaller via `scripts/build.sh`.
|
||||
- Output: `artifacts/<name>.tgz` containing a one-dir bundle by default (recommended) or a one-file binary.
|
||||
- Binary/folder name: defaults to `msty-mlx-studio` (configurable via `BIN_NAME`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Apple Silicon Mac.
|
||||
- Xcode Command Line Tools (`xcode-select --install`).
|
||||
- Python 3.9+ available as `python3`.
|
||||
|
||||
## Build Script
|
||||
|
||||
```bash
|
||||
# Onedir build (recommended)
|
||||
scripts/build.sh
|
||||
|
||||
# Onefile build
|
||||
ONEFILE=1 scripts/build.sh
|
||||
|
||||
# Versioned archive name
|
||||
VERSION=1.2.3 scripts/build.sh
|
||||
|
||||
# Custom binary name (default is msty-mlx-studio)
|
||||
BIN_NAME=my-cli scripts/build.sh
|
||||
|
||||
# Skip optional Hugging Face Xet plugin (installed by default)
|
||||
USE_HF_XET=0 scripts/build.sh
|
||||
```
|
||||
|
||||
The script creates a virtualenv, installs runtime deps from `requirements.txt` (`mlx`, `mlx-lm`, `fastapi`, `uvicorn`, etc.) plus the project itself, then builds a PyInstaller bundle and packages it into `artifacts/<name>.tgz`.
|
||||
|
||||
### Build options reference
|
||||
|
||||
Environment variables / flags:
|
||||
|
||||
- `VERSION` – temporary override for `mlxk2.__version__` while bundling.
|
||||
- `BIN_NAME` – output folder/binary name (default `msty-mlx-studio`).
|
||||
- `ONEFILE=1` – build a single-file PyInstaller binary (defaults to one-dir).
|
||||
- `USE_HF_XET=0` – skip the optional Hugging Face Xet plugin.
|
||||
- `PYTHON_EXE=/abs/path/python3` – force a specific interpreter for the build venv.
|
||||
- `MLX_WHEEL=/abs/path/mlx.whl` or `--mlx-wheel` – install a custom `mlx` wheel after deps.
|
||||
- `MLX_LM_WHEEL=/abs/path/mlx_lm.whl` or `--mlx-lm-wheel` – install a matching custom `mlx-lm` wheel.
|
||||
- `PIP_ARGS="..."` – forwarded to pip install commands if you need custom indexes.
|
||||
|
||||
Quick test after build:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/mlxk-test
|
||||
tar -xzf artifacts/msty-mlx-studio.tgz -C ~/mlxk-test
|
||||
xattr -dr com.apple.quarantine ~/mlxk-test/msty-mlx-studio
|
||||
~/mlxk-test/msty-mlx-studio/msty-mlx-studio --version # prints just the numeric version
|
||||
```
|
||||
|
||||
Where binaries land:
|
||||
- Onedir: `dist/<name>/<name>` (folder with libs + executable)
|
||||
- Onefile: `dist/<name>` (single executable)
|
||||
|
||||
## Server behavior
|
||||
|
||||
- The packaged binary runs the HTTP server in-process by default (no uvicorn supervisor). The native launcher sets `MLXK2_SUPERVISE=0` so there’s no extra python supervisor process.
|
||||
- Pressing Ctrl-C stops the process; from Electron, send `SIGINT` to the spawned process handle or `SIGKILL` if needed.
|
||||
|
||||
Process naming:
|
||||
- The release pipeline compiles a native launcher so the parent process name appears as the launcher binary (e.g., `msty-mlx-studio`).
|
||||
- You’ll see one `python` child process while the server runs. Stopping the parent with `SIGINT`/`SIGTERM` stops the child promptly; a second signal escalates to `SIGKILL`.
|
||||
|
||||
Version reporting:
|
||||
- The packaged binary prints only the numeric version for `--version`.
|
||||
- For structured output, use `--version --json` to get `{ cli_version, json_api_spec_version }`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Optional Hugging Face Xet plugin (`hf_xet`) is installed by default; disable with `USE_HF_XET=0` if a wheel isn’t available.
|
||||
- The script copies `LICENSE` into the archive.
|
||||
- No upstream source is modified. All adjustments happen in the generated entry shim used only at build time.
|
||||
- The previous v1 helper worker is no longer built or packaged.
|
||||
|
||||
### Custom Python runtime
|
||||
|
||||
To freeze with a specific Python (and embed that version in the bundle), place it at `python/bin/python3` in the repo. The build script will automatically prefer this interpreter for creating the build venv and running PyInstaller. Alternatively, set `PYTHON_EXE=/absolute/path/to/python3` to override.
|
||||
|
||||
#### Custom MLX / MLX-LM wheels
|
||||
|
||||
If you need to ship custom builds:
|
||||
|
||||
```bash
|
||||
# Auto-detected defaults: scripts/mlx-custom.whl, scripts/mlx-lm-custom.whl
|
||||
MLX_WHEEL=/abs/path/mlx.whl \
|
||||
MLX_LM_WHEEL=/abs/path/mlx_lm.whl \
|
||||
scripts/build.sh
|
||||
|
||||
# Equivalent flag form
|
||||
scripts/build.sh \
|
||||
--mlx-wheel /abs/path/mlx.whl \
|
||||
--mlx-lm-wheel /abs/path/mlx_lm.whl
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `mlx` and `mlx-lm` are filtered out of `requirements.txt`; your wheels install last and override PyPI pins.
|
||||
- Provide compatible builds (matching Python ABI / MLX API surface) to avoid runtime mismatches.
|
||||
- Use `--check-mlx-stack` (see below) on the finished binary to confirm both packages import cleanly.
|
||||
|
||||
### Version override
|
||||
|
||||
Set `VERSION` when invoking the build to stamp the bundle with a release number:
|
||||
|
||||
```bash
|
||||
VERSION=2.0.0b4 scripts/build.sh
|
||||
```
|
||||
|
||||
Details:
|
||||
- The script temporarily rewrites `mlxk2/__version__` (and related README references) during the build.
|
||||
- Files are restored to their original contents after the script exits, so your working tree stays clean.
|
||||
|
||||
Steps:
|
||||
- Put your desired macOS arm64 Python at `python/bin/python3`.
|
||||
- Run `scripts/build.sh`.
|
||||
- Verify the embedded Python in the resulting binary:
|
||||
|
||||
```bash
|
||||
./dist/msty-mlx-studio/msty-mlx-studio --python-info
|
||||
# or
|
||||
MLXK2_PY_INFO=1 ./dist/msty-mlx-studio/msty-mlx-studio --version
|
||||
```
|
||||
|
||||
This prints JSON like:
|
||||
```
|
||||
{
|
||||
"python_version": "3.12.5",
|
||||
"executable": "/path/to/dist/msty-mlx-studio/msty-mlx-studio",
|
||||
"prefix": "/path/to/dist/msty-mlx-studio",
|
||||
"base_prefix": null,
|
||||
"frozen": true,
|
||||
"meipass": "/path/to/_MEIxxxx",
|
||||
"platform": "darwin"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `python_version` confirms which interpreter is embedded. `executable` is the frozen launcher (expected for PyInstaller).
|
||||
- Ensure your chosen Python is compatible with the pinned PyInstaller (>= 6.6). For very new Python releases, update PyInstaller if needed.
|
||||
|
||||
### Quick diagnostics on the output bundle
|
||||
|
||||
- `./dist/<name>/<name> --python-info` – JSON summary of the embedded interpreter.
|
||||
- `./dist/<name>/<name> --mlx-info` – JSON showing `mlx` version, module path, and dist-info directory.
|
||||
- `./dist/<name>/<name> --pkg-info mlx_lm` – same diagnostic for `mlx-lm`.
|
||||
- `./dist/<name>/<name> --check-mlx-stack` – verifies both `mlx` and `mlx_lm` import successfully.
|
||||
|
||||
Use these after applying custom wheels to ensure the expected versions were bundled.
|
||||
|
||||
### Clearing quarantine during development
|
||||
|
||||
macOS Gatekeeper may block unsigned interpreters inside the bundle. During local builds/tests, run:
|
||||
|
||||
```bash
|
||||
scripts/clear_quarantine_python.sh dist/<name>/python
|
||||
```
|
||||
|
||||
Or target your Electron resources path. This recurses through the folder and removes the `com.apple.quarantine` extended attribute, reporting any leftovers. (Sign and notarize for production distribution.)
|
||||
|
||||
### Verify signing & notarization (local)
|
||||
|
||||
1) Extract TGZ to a temp folder and locate the launcher.
|
||||
2) Reapply quarantine (simulating a download):
|
||||
`xattr -w com.apple.quarantine "0081;$(date +%s);mlxk2;GatekeeperTest" <path-to-launcher>`
|
||||
3) Gatekeeper check (should be accepted as Notarized Developer ID):
|
||||
`spctl -a -t exec -vv <path-to-launcher>`
|
||||
4) Codesign verification on key Mach-O files:
|
||||
`codesign --verify --deep --strict --verbose=2 <path-to-launcher>`
|
||||
`codesign --verify --strict --verbose=2 <bundle>/_vendor/pydantic_core/_pydantic_core*.so`
|
||||
|
||||
## Runtime Integration
|
||||
|
||||
- Our Electron app handles model downloads directly (see `ELECTRON_INTEGRATION_GUIDE.md`).
|
||||
- The packaged CLI is used for listing/showing models, running prompts, and serving the HTTP API.
|
||||
- Set `HF_HOME` to the same cache location used by the Electron downloader so the CLI sees downloaded models.
|
||||
|
||||
That’s it—build, ship, and run the CLI as a single-folder artifact without patching upstream.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Build-Time Patches
|
||||
|
||||
Patches in `scripts/patches/` are applied during the build process (`build.sh` and `build_external_python.sh`) to modify source files before packaging. They are automatically cleaned up on exit via the `cleanup_patches()` trap.
|
||||
|
||||
These patches exist separately from the main source to keep the development codebase clean while applying build-specific fixes and enhancements to the packaged binary.
|
||||
|
||||
## Status
|
||||
|
||||
| Patch | Upstream Fix? | Why Still Needed |
|
||||
|---|---|---|
|
||||
| **cli.patch** | No | `main` still hardcodes `supervise=True`. Build scripts still use the two-tier parent/child architecture requiring `MLXK2_SUPERVISE=0` to prevent double supervision. |
|
||||
| **server_models.patch** | No | `main` still filters out unhealthy models with `health != "healthy"` check. |
|
||||
| **server_streaming_usage.patch** | No | `main` still uses rough `len(text.split()) * 1.3` estimation in `count_tokens()` with no tokenizer parameter. Streaming responses have no `usage` block. |
|
||||
| **runner_decode.patch** | No | `main` still uses `_decode_tokens()` which resets and re-processes all tokens on every call (O(n^2)). The issue is in this project's usage pattern, not an upstream mlx-lm bug. |
|
||||
---
|
||||
|
||||
## cli.patch
|
||||
|
||||
**File:** `mlxk2/cli.py`
|
||||
|
||||
Adds the `MLXK2_SUPERVISE` environment variable to allow disabling subprocess uvicorn supervision.
|
||||
|
||||
The packaged binary uses a two-tier server architecture: a parent process (PyInstaller wrapper) spawns and supervises a child process that runs the actual uvicorn server. The parent handles graceful shutdown (SIGTERM then SIGKILL). Without this patch, the child would also try to spawn its own supervised subprocess, creating redundant nesting. Setting `MLXK2_SUPERVISE=0` lets the child run the server in-process.
|
||||
|
||||
## server_models.patch
|
||||
|
||||
**File:** `mlxk2/core/server_base.py`
|
||||
|
||||
Removes the `health != "healthy"` filter from the `/v1/models` endpoint, keeping only the `runtime_compatible` filter.
|
||||
|
||||
The `health` field indicates file integrity issues (corrupted files, missing snapshots), while `runtime_compatible` indicates feature compatibility (e.g., vision model without mlx-vlm installed). By filtering out unhealthy models entirely, users had no way to see why a model disappeared from the list. With this patch, unhealthy models remain visible with their status, so users can diagnose and repair them.
|
||||
|
||||
## server_streaming_usage.patch
|
||||
|
||||
**File:** `mlxk2/core/server_base.py`
|
||||
|
||||
Adds token usage statistics to streaming responses and switches `count_tokens()` from rough word-count estimation to actual tokenizer-based counting.
|
||||
|
||||
The OpenAI streaming API spec includes a `usage` block (`prompt_tokens`, `completion_tokens`, `total_tokens`) in the final SSE chunk. Without this patch, streaming responses omit usage entirely, and non-streaming responses use an inaccurate `len(text.split()) * 1.3` approximation. The patch accumulates generated text during streaming, counts tokens via `tokenizer.encode()`, and includes the usage block in the final chunk.
|
||||
|
||||
## runner_decode.patch
|
||||
|
||||
**File:** `mlxk2/core/runner/__init__.py`
|
||||
|
||||
Replaces `self._decode_tokens()` with `self.tokenizer.decode()` to fix an O(n^2) performance regression during streaming inference.
|
||||
|
||||
The custom `_decode_tokens()` method calls `detokenizer.reset()` and re-processes all tokens on every invocation. When called in the streaming loop for each new token, this causes quadratic complexity, dropping inference speed from ~200 tokens/sec to ~5 tokens/sec. Using `tokenizer.decode()` directly is a single-pass O(n) operation and handles BPE space markers correctly.
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Release Guide (External Python, Signed + Notarized)
|
||||
|
||||
This project ships a single-folder bundle that runs with a Python you provide. Use one command to build, sign, package, and notarize a release.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS Apple Silicon runner (local or GitHub Actions).
|
||||
- Embedded Python at `python/bin/python3` (arm64).
|
||||
- Apple Developer ID Application certificate (for codesign).
|
||||
- Apple ID app-specific password (for notarization).
|
||||
- Cloudflare R2 credentials if uploading.
|
||||
|
||||
## One‑shot local release (defaults)
|
||||
|
||||
```
|
||||
# VERSION is picked from package.json if not set (override by exporting VERSION)
|
||||
# export VERSION=2.0.0b6
|
||||
export SIGN_ID="Developer ID Application: Your Name (TEAMID)"
|
||||
export APPLE_ID="you@apple.com"
|
||||
export APPLE_TEAM_ID="TEAMID"
|
||||
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
|
||||
|
||||
# Minimal: external variant, uses ./python/bin/python3 automatically,
|
||||
# disables library validation by default, signs + notarizes and emits TGZ
|
||||
scripts/release.sh
|
||||
|
||||
### Automatic CPython download
|
||||
|
||||
If you don’t have `./python` prepared, provide a standalone CPython URL and the release script will download + extract it and use it as the embedded interpreter:
|
||||
|
||||
```
|
||||
export SIGN_ID="Developer ID Application: Your Name (TEAMID)"
|
||||
export APPLE_ID=… APPLE_TEAM_ID=… APPLE_APP_SPECIFIC_PASSWORD=…
|
||||
|
||||
CPYTHON_URL="https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.10.19+20251014-aarch64-apple-darwin-install_only.tar.gz" \
|
||||
scripts/release.sh --variant external --disable-libval
|
||||
```
|
||||
|
||||
By default, it extracts into `./python` and uses `./python/bin/python3`.
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- Folder: `dist-ext-python/msty-mlx-studio` (includes `python/` and `_vendor/`)
|
||||
- Tgz (for your in‑app download): `artifacts/msty-mlx-studio-<VERSION>.tgz` (signed)
|
||||
|
||||
Notes:
|
||||
- `--disable-libval` adds the entitlement `com.apple.security.cs.disable-library-validation` to allow loading signed third‑party libraries while using hardened runtime.
|
||||
- For a local dry-run without notarization, add `--skip-notarize`.
|
||||
|
||||
## NPM helper
|
||||
|
||||
```
|
||||
# Uses the embedded python at ./python/bin/python3
|
||||
# Environment variables for signing/notarization must be set as above
|
||||
npm run mlx:release
|
||||
```
|
||||
|
||||
## GitHub Actions CI
|
||||
|
||||
Use `.github/workflows/release.yml`:
|
||||
- Triggers manually with `workflow_dispatch`.
|
||||
- Requires secrets:
|
||||
- `CSC_NAME` – your codesign identity (Developer ID Application: … (TEAMID))
|
||||
- `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_SPECIFIC_PASSWORD`
|
||||
- (Optional) `MACOS_CERT_P12`, `MACOS_CERT_PASSWORD` – base64 .p12 if importing the cert in CI
|
||||
- (Optional) `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_ENDPOINT` – for Cloudflare R2 upload (S3 compatible)
|
||||
- (Optional) Input `cpython_url` – if provided, CI will download CPython automatically; otherwise expects `./python/bin/python3` checked in.
|
||||
- (Optional) Input `r2_path` – custom path under the bucket (default `releases/<version>/`).
|
||||
|
||||
Artifacts:
|
||||
- Uploaded to the workflow run (`artifacts/*.tgz`).
|
||||
- Optionally uploaded to R2 at `s3://$R2_BUCKET/releases/<version>/`.
|
||||
|
||||
## Process model and signals
|
||||
|
||||
- The release builds a native launcher to keep the parent process name as `msty-mlx-studio`.
|
||||
- The launcher sets `MLXK2_SUPERVISE=0` so uvicorn runs in-process. Expect one `python` child under the launcher.
|
||||
- Stop via `SIGINT`/`SIGTERM` to the launcher; the python child receives the same and exits promptly. A second signal escalates to `SIGKILL`.
|
||||
|
||||
## Verify signing & notarization
|
||||
|
||||
Extract the TGZ, reapply quarantine to the launcher, then run Gatekeeper and codesign checks:
|
||||
|
||||
```
|
||||
TMP=$(mktemp -d)
|
||||
tar -xzf artifacts/msty-mlx-studio-<VERSION>.tgz -C "$TMP"
|
||||
APP="$TMP/msty-mlx-studio/msty-mlx-studio"
|
||||
xattr -w com.apple.quarantine "0081;$(date +%s);mlxk2;GatekeeperTest" "$APP"
|
||||
spctl -a -t exec -vv "$APP" # Expect: accepted (Notarized Developer ID)
|
||||
codesign --verify --deep --strict --verbose=2 "$APP"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Gatekeeper blocks embedded Python during build:
|
||||
- The release script pre‑signs the embedded interpreter and clears quarantine.
|
||||
- pydantic_core import error in PyInstaller bundle:
|
||||
- We collect `pydantic_core` explicitly.
|
||||
- MLX `libmlx.dylib` missing:
|
||||
- External bundle places it under `_vendor/`. For PyInstaller, the build adds it under `dist/<name>/_internal/`.
|
||||
|
||||
## Minimal flags
|
||||
|
||||
We intentionally avoid extra knobs. Provide only:
|
||||
- `--variant external` (default)
|
||||
- `--embedded-python <path>` (required for external)
|
||||
- `--disable-libval` (recommended)
|
||||
- Signing/notary env vars as shown above.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "mlx-knife-build-tools",
|
||||
"version": "2.0.0b3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"mlx:clear-quarantine:python": "scripts/clear_quarantine_python.sh python",
|
||||
"mlx:sign:python:adhoc": "SIGN_ID=- scripts/sign_and_notarize.sh python --no-runtime",
|
||||
"mlx:sign:python:adhoc:libval": "SIGN_ID=- scripts/sign_and_notarize.sh python --disable-libval",
|
||||
"mlx:dev-unblock:python": "npm run mlx:clear-quarantine:python && npm run mlx:sign:python:adhoc",
|
||||
"mlx:dev-unblock:python:libval": "npm run mlx:clear-quarantine:python && npm run mlx:sign:python:adhoc:libval",
|
||||
"mlx:build:external": "EMBEDDED_PYTHON=\"$(pwd)/python/bin/python3\" scripts/build_external_python.sh",
|
||||
"mlx:verify:external": "MLXK_PYTHON=\"$(pwd)/python/bin/python3\" ./dist-ext-python/msty-mlx-studio/msty-mlx-studio --python-info && MLXK_PYTHON=\"$(pwd)/python/bin/python3\" ./dist-ext-python/msty-mlx-studio/msty-mlx-studio --check-mlx-stack",
|
||||
"mlx:release": "scripts/release.sh"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# mlx_knife requirements
|
||||
# Core dependencies for HuggingFace model management
|
||||
|
||||
huggingface-hub>=0.34.0
|
||||
requests>=2.32.0
|
||||
mlx-lm==0.30.5
|
||||
mlx>=0.30.0
|
||||
|
||||
# API Server dependencies (for 'mlxk server' command)
|
||||
fastapi>=0.116.0
|
||||
uvicorn>=0.35.0
|
||||
pydantic>=2.11.0
|
||||
|
||||
# Test dependencies (for FastAPI TestClient)
|
||||
httpx>=0.27.0
|
||||
|
||||
# Vision Language Models support
|
||||
mlx-vlm==0.3.10
|
||||
|
||||
# Note: Python 3.10+ required for vision support, tested on Apple Silicon M1/M2/M3/M4
|
||||
@@ -0,0 +1,101 @@
|
||||
# External Python Mode (Electron-embedded Python)
|
||||
|
||||
This variant does not use PyInstaller. Instead, it vendors all Python deps (including `mlxk2`) into a folder and runs against the Python interpreter you ship inside your Electron app (`MyApp.app/Contents/Resources/python/bin/python3`).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Recommended: point to your embedded Python
|
||||
EMBEDDED_PYTHON="/path/to/MyApp.app/Contents/Resources/python/bin/python3" \
|
||||
scripts/build_external_python.sh
|
||||
|
||||
# Dev fallback (uses ./python/bin/python3 if present, else system python3)
|
||||
scripts/build_external_python.sh
|
||||
```
|
||||
|
||||
Output: `dist-ext-python/<BIN_NAME>/` (default `msty-mlx-studio/`), containing:
|
||||
- `_vendor/` – vendored site-packages (deps + project)
|
||||
- `msty-mlx-studio` – launcher that runs `-m mlxk2.cli` with `PYTHONPATH=_vendor`
|
||||
|
||||
Verify the Python in use:
|
||||
|
||||
```bash
|
||||
./dist-ext-python/msty-mlx-studio/msty-mlx-studio --python-info
|
||||
|
||||
### Building without executing the embedded Python (avoids Gatekeeper during dev)
|
||||
|
||||
If macOS blocks running your embedded Python during the build, you can vendor wheels for a target version without executing that interpreter:
|
||||
|
||||
```bash
|
||||
TARGET_PY_VERSION=3.10 \
|
||||
VENDOR_VIA_WHEELS=1 \
|
||||
scripts/build_external_python.sh
|
||||
```
|
||||
|
||||
Notes:
|
||||
- This uses your system Python to `pip download` wheels for macOS arm64 and the specified Python version, then unpacks them into `_vendor`.
|
||||
- At runtime, the Electron-embedded Python imports from `_vendor` via `PYTHONPATH`.
|
||||
- For compiled packages, you must choose a `TARGET_PY_VERSION` that matches your embedded Python minor version (e.g., 3.10 → cp310 wheels).
|
||||
```
|
||||
|
||||
## Packaging into Electron
|
||||
|
||||
Place the folder under your app resources, for example:
|
||||
|
||||
```
|
||||
MyApp.app/Contents/Resources/
|
||||
python/bin/python3 # your embedded Python
|
||||
mlxk2/
|
||||
_vendor/
|
||||
msty-mlx-studio # launcher
|
||||
```
|
||||
|
||||
### Option A: Use the launcher
|
||||
|
||||
- Command: `join(process.resourcesPath, 'mlxk2', 'msty-mlx-studio')`
|
||||
- Env: set `RESOURCES_PATH=process.resourcesPath` (so the launcher finds `python/bin/python3`).
|
||||
|
||||
```js
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
const launcher = join(process.resourcesPath, 'mlxk2', 'msty-mlx-studio');
|
||||
|
||||
export function startServer({ host = '127.0.0.1', port = 8000, maxTokens } = {}) {
|
||||
const args = ['serve', '--host', host, '--port', String(port)];
|
||||
if (maxTokens != null) args.push('--max-tokens', String(maxTokens));
|
||||
const env = { ...process.env, RESOURCES_PATH: process.resourcesPath };
|
||||
const child = spawn(launcher, args, { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
return child;
|
||||
}
|
||||
```
|
||||
|
||||
### Option B: Call Python directly
|
||||
|
||||
- Python: `join(process.resourcesPath, 'python', 'bin', 'python3')`
|
||||
- Vendor: `join(process.resourcesPath, 'mlxk2', '_vendor')`
|
||||
- Env: `PYTHONPATH=<vendor>[:$PYTHONPATH]`, `PYTHONNOUSERSITE=1`
|
||||
|
||||
```js
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
export function startServer({ host = '127.0.0.1', port = 8000, maxTokens } = {}) {
|
||||
const pythonPath = join(process.resourcesPath, 'python', 'bin', 'python3');
|
||||
const vendor = join(process.resourcesPath, 'mlxk2', '_vendor');
|
||||
const args = ['-m', 'mlxk2.cli', 'serve', '--host', host, '--port', String(port)];
|
||||
if (maxTokens != null) args.push('--max-tokens', String(maxTokens));
|
||||
const env = { ...process.env, PYTHONNOUSERSITE: '1', PYTHONPATH: vendor };
|
||||
const child = spawn(pythonPath, args, { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
||||
return child;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- You are responsible for shipping a compatible macOS arm64 Python with all required system libs.
|
||||
- If using private or offline wheels, pass extra pip args: `PIP_ARGS="--no-index --find-links /path/to/wheels" scripts/build_external_python.sh`.
|
||||
- To sanity check deps at runtime:
|
||||
`PYTHONPATH=<vendor> <embedded-python> -c "import mlxk2, fastapi, uvicorn, mlx, mlx_lm; print('ok')"`
|
||||
Executable
+551
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build and bundle a macOS arm64 (Apple Silicon) CLI tarball using PyInstaller.
|
||||
# Invoked as scripts/build.sh
|
||||
#
|
||||
# Outputs: artifacts/<name>.tgz containing dist/<name>/...
|
||||
# Optional env:
|
||||
# ONEFILE=1 -> build single-file binary (defaults to onedir for reliability)
|
||||
# VERSION=1.1 -> append version to tarball name
|
||||
# BIN_NAME=my-cli -> override binary/folder name (default: msty-mlx-studio)
|
||||
# USE_HF_XET=0 -> skip installing Hugging Face Xet plugin
|
||||
#
|
||||
# Requirements (run on an Apple Silicon Mac):
|
||||
# - Xcode Command Line Tools
|
||||
# - Python 3.9+ available as `python3`
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Temporary version override (VERSION env)
|
||||
# -----------------------------------------------------------------------------
|
||||
ORIG_VERSION="$(python3 - <<'PY'
|
||||
import pathlib, re
|
||||
text = pathlib.Path("mlxk2/__init__.py").read_text()
|
||||
m = re.search(r'__version__\s*=\s*"([^"]+)"', text)
|
||||
print(m.group(1) if m else "0.0.0")
|
||||
PY
|
||||
)"
|
||||
TARGET_VERSION="${VERSION:-$ORIG_VERSION}"
|
||||
TMP_INIT=""
|
||||
TMP_README=""
|
||||
RESTORE_VERSION=0
|
||||
APPLIED_PATCHES=()
|
||||
|
||||
cleanup_version() {
|
||||
if [[ "$RESTORE_VERSION" == "1" ]]; then
|
||||
if [[ -n "$TMP_INIT" && -f "$TMP_INIT" ]]; then
|
||||
cp "$TMP_INIT" mlxk2/__init__.py
|
||||
rm -f "$TMP_INIT"
|
||||
fi
|
||||
if [[ -n "$TMP_README" && -f "$TMP_README" ]]; then
|
||||
cp "$TMP_README" README.md
|
||||
rm -f "$TMP_README"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
cleanup_patches() {
|
||||
if [[ "${#APPLIED_PATCHES[@]}" -gt 0 ]]; then
|
||||
for pf in "${APPLIED_PATCHES[@]}"; do
|
||||
patch -l -p1 -R < "$pf" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
}
|
||||
cleanup_all() {
|
||||
cleanup_version
|
||||
cleanup_patches
|
||||
}
|
||||
trap cleanup_all EXIT
|
||||
|
||||
if [[ "$TARGET_VERSION" != "$ORIG_VERSION" ]]; then
|
||||
echo "[version] Updating project version: $ORIG_VERSION -> $TARGET_VERSION"
|
||||
TMP_INIT="$(mktemp)"
|
||||
cp mlxk2/__init__.py "$TMP_INIT"
|
||||
if [[ -f README.md ]]; then
|
||||
TMP_README="$(mktemp)"
|
||||
cp README.md "$TMP_README"
|
||||
fi
|
||||
RESTORE_VERSION=1
|
||||
python3 - "$ORIG_VERSION" "$TARGET_VERSION" <<'PY'
|
||||
import sys
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
old, new = sys.argv[1:3]
|
||||
|
||||
def tag(ver: str) -> str:
|
||||
if 'b' in ver:
|
||||
base, beta = ver.split('b', 1)
|
||||
if beta and beta.isdigit():
|
||||
return f"v{base}-beta.{beta}"
|
||||
return f"v{ver}"
|
||||
|
||||
init_path = pathlib.Path("mlxk2/__init__.py")
|
||||
text = init_path.read_text()
|
||||
text = re.sub(r'__version__\s*=\s*".*?"', f'__version__ = "{new}"', text)
|
||||
init_path.write_text(text)
|
||||
|
||||
readme_path = pathlib.Path("README.md")
|
||||
if readme_path.exists():
|
||||
readme = readme_path.read_text()
|
||||
readme = readme.replace(old, new)
|
||||
readme = readme.replace(tag(old), tag(new))
|
||||
readme_path.write_text(readme)
|
||||
PY
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Apply local CLI patch (idempotent)
|
||||
# -----------------------------------------------------------------------------
|
||||
apply_patch_with_cleanup() {
|
||||
local patch_file="$1"
|
||||
[[ -f "$patch_file" ]] || return
|
||||
if patch --dry-run -l -p1 < "$patch_file" >/dev/null 2>&1; then
|
||||
echo "[patch] Applying patch from $patch_file"
|
||||
patch -l -p1 < "$patch_file"
|
||||
APPLIED_PATCHES+=("$patch_file")
|
||||
elif patch --dry-run -R -l -p1 < "$patch_file" >/dev/null 2>&1; then
|
||||
echo "[patch] Patch already applied; skipping ($patch_file)"
|
||||
else
|
||||
echo "ERROR: Failed to apply patch: $patch_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/cli.patch"
|
||||
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/server_models.patch"
|
||||
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/server_streaming_usage.patch"
|
||||
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/runner_decode.patch"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CLI flags (custom wheels)
|
||||
# -----------------------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mlx-wheel)
|
||||
shift
|
||||
if [[ -n "${1:-}" ]]; then
|
||||
export MLX_WHEEL="${1:-}"
|
||||
shift || true
|
||||
else
|
||||
echo "ERROR: --mlx-wheel requires a path" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--mlx-lm-wheel)
|
||||
shift
|
||||
if [[ -n "${1:-}" ]]; then
|
||||
export MLX_LM_WHEEL="${1:-}"
|
||||
shift || true
|
||||
else
|
||||
echo "ERROR: --mlx-lm-wheel requires a path" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unknown flag: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Platform checks
|
||||
# -----------------------------------------------------------------------------
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "This script must run on macOS." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(uname -m)" != "arm64" ]]; then
|
||||
echo "This script targets Apple Silicon (arm64) only." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_NAME="${BIN_NAME:-${APP_NAME:-msty-mlx-studio}}"
|
||||
BUILD_VENV=".venv-build"
|
||||
DIST_DIR="$ROOT_DIR/dist"
|
||||
ART_DIR="$ROOT_DIR/artifacts"
|
||||
SPEC_DIR="$ROOT_DIR/.pyi-spec"
|
||||
|
||||
mkdir -p "$ART_DIR" "$SPEC_DIR"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Create build virtualenv
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "[1/3] Creating build venv..."
|
||||
CUSTOM_PY_DIR="$ROOT_DIR/python"
|
||||
if [[ -n "${PYTHON_EXE:-}" ]]; then
|
||||
echo "Using override Python (PYTHON_EXE): $PYTHON_EXE"
|
||||
elif [[ -x "$CUSTOM_PY_DIR/bin/python3" ]]; then
|
||||
PYTHON_EXE="$CUSTOM_PY_DIR/bin/python3"
|
||||
echo "Using project Python: $PYTHON_EXE"
|
||||
else
|
||||
PYTHON_EXE="$(command -v python3)"
|
||||
echo "Using system Python: $PYTHON_EXE"
|
||||
fi
|
||||
|
||||
"$PYTHON_EXE" -m venv "$BUILD_VENV"
|
||||
source "$BUILD_VENV/bin/activate"
|
||||
|
||||
if ! python -m pip --version >/dev/null 2>&1; then
|
||||
echo "Bootstrapping pip via ensurepip..."
|
||||
python -m ensurepip --upgrade || true
|
||||
fi
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Install dependencies and project
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "[2/3] Installing project, deps and build tools..."
|
||||
CUSTOM_MLX_WHEEL="${MLX_WHEEL:-}"
|
||||
if [[ -z "$CUSTOM_MLX_WHEEL" && -f "$ROOT_DIR/scripts/mlx-custom.whl" ]]; then
|
||||
CUSTOM_MLX_WHEEL="$ROOT_DIR/scripts/mlx-custom.whl"
|
||||
fi
|
||||
if [[ -n "$CUSTOM_MLX_WHEEL" ]]; then
|
||||
echo "[opt] Using custom MLX wheel: $CUSTOM_MLX_WHEEL"
|
||||
[[ -f "$CUSTOM_MLX_WHEEL" ]] || { echo "ERROR: MLX wheel not found: $CUSTOM_MLX_WHEEL" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
CUSTOM_MLX_LM_WHEEL="${MLX_LM_WHEEL:-}"
|
||||
if [[ -z "$CUSTOM_MLX_LM_WHEEL" && -f "$ROOT_DIR/scripts/mlx-lm-custom.whl" ]]; then
|
||||
CUSTOM_MLX_LM_WHEEL="$ROOT_DIR/scripts/mlx-lm-custom.whl"
|
||||
fi
|
||||
if [[ -n "$CUSTOM_MLX_LM_WHEEL" ]]; then
|
||||
echo "[opt] Using custom MLX-LM wheel: $CUSTOM_MLX_LM_WHEEL"
|
||||
[[ -f "$CUSTOM_MLX_LM_WHEEL" ]] || { echo "ERROR: MLX-LM wheel not found: $CUSTOM_MLX_LM_WHEEL" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
if [[ -f requirements.txt ]]; then
|
||||
if [[ -n "$CUSTOM_MLX_WHEEL" || -n "$CUSTOM_MLX_LM_WHEEL" ]]; then
|
||||
REQ_TMP="$(mktemp)"
|
||||
awk 'BEGIN{IGNORECASE=0} {
|
||||
line=$0; trimmed=line; gsub(/^[ \t]+/,"",trimmed);
|
||||
if (trimmed ~ /^#/ || trimmed ~ /^$/) { print line; next }
|
||||
if (ENVIRON["FILTER_MLX"] && trimmed ~ /^mlx([ \t]|[><=]|$)/) { next }
|
||||
if (ENVIRON["FILTER_MLX_LM"] && trimmed ~ /^mlx-lm([ \t]|[><=]|$)/) { next }
|
||||
print line
|
||||
}' FILTER_MLX=$([[ -n "$CUSTOM_MLX_WHEEL" ]] && echo 1) FILTER_MLX_LM=$([[ -n "$CUSTOM_MLX_LM_WHEEL" ]] && echo 1) requirements.txt > "$REQ_TMP"
|
||||
python -m pip install ${PIP_ARGS:-} -r "$REQ_TMP"
|
||||
rm -f "$REQ_TMP"
|
||||
else
|
||||
python -m pip install ${PIP_ARGS:-} -r requirements.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${MLX_REPO_REF:-}" ]]; then
|
||||
echo "[opt] Overriding MLX from: ${MLX_REPO_REF}"
|
||||
python -m pip install ${PIP_ARGS:-} --no-deps "${MLX_REPO_REF}"
|
||||
fi
|
||||
if [[ -n "${MLX_LM_REPO_REF:-}" ]]; then
|
||||
echo "[opt] Overriding MLX-LM from: ${MLX_LM_REPO_REF}"
|
||||
python -m pip install ${PIP_ARGS:-} --no-deps "${MLX_LM_REPO_REF}"
|
||||
fi
|
||||
|
||||
python -m pip install ${PIP_ARGS:-} .
|
||||
|
||||
if [[ "${USE_HF_XET:-1}" == "1" ]]; then
|
||||
echo "[opt] Installing Hugging Face Xet plugin..."
|
||||
python -m pip install "huggingface_hub[hf_xet]" || true
|
||||
fi
|
||||
|
||||
if [[ -n "$CUSTOM_MLX_WHEEL" ]]; then
|
||||
echo "[opt] Installing custom MLX wheel: $CUSTOM_MLX_WHEEL"
|
||||
python -m pip install ${PIP_ARGS:-} --force-reinstall "$CUSTOM_MLX_WHEEL"
|
||||
fi
|
||||
if [[ -n "$CUSTOM_MLX_LM_WHEEL" ]]; then
|
||||
echo "[opt] Installing custom MLX-LM wheel: $CUSTOM_MLX_LM_WHEEL"
|
||||
python -m pip install ${PIP_ARGS:-} --force-reinstall "$CUSTOM_MLX_LM_WHEEL"
|
||||
fi
|
||||
|
||||
python -m pip install "pyinstaller>=6.6"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Entry script for PyInstaller
|
||||
# -----------------------------------------------------------------------------
|
||||
ENTRY_SCRIPT="$ROOT_DIR/.entry_mlxk_build.py"
|
||||
cat > "$ENTRY_SCRIPT" <<'PY'
|
||||
"""
|
||||
Custom entry shim for PyInstaller bundle.
|
||||
|
||||
Implements supervised shutdown by default in a frozen binary:
|
||||
- Parent process patches mlxk2.operations.serve.start_server to spawn a child
|
||||
process (the same frozen binary) that runs the server in-process.
|
||||
- Parent supervises: SIGTERM on first Ctrl-C, SIGKILL on timeout/second Ctrl-C.
|
||||
|
||||
Also supports a child mode keyed by MLXK2_CHILD_SERVER=1 to run the server
|
||||
directly without going through CLI argument parsing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def _to_bool(val: str) -> bool:
|
||||
return str(val).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# Fast path: strip CLI name from version output for compatibility
|
||||
if "--version" in sys.argv and "--json" not in sys.argv and "--python-info" not in sys.argv:
|
||||
try:
|
||||
from mlxk2 import __version__ as _ver
|
||||
except Exception:
|
||||
_ver = "0.0.0"
|
||||
print(_ver)
|
||||
sys.exit(0)
|
||||
|
||||
# Diagnostic: print embedded Python details
|
||||
if "--python-info" in sys.argv or os.environ.get("MLXK2_PY_INFO") == "1":
|
||||
info = {
|
||||
"python_version": sys.version.split(" (", 1)[0],
|
||||
"executable": sys.executable,
|
||||
"prefix": sys.prefix,
|
||||
"base_prefix": getattr(sys, "base_prefix", None),
|
||||
"frozen": bool(getattr(sys, "frozen", False)),
|
||||
"meipass": getattr(sys, "_MEIPASS", None),
|
||||
"platform": sys.platform,
|
||||
}
|
||||
print(json.dumps(info, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
# Diagnostic: package info helpers
|
||||
if "--mlx-info" in sys.argv or "--pkg-info" in sys.argv:
|
||||
try:
|
||||
if "--mlx-info" in sys.argv:
|
||||
pkg = "mlx"
|
||||
else:
|
||||
idx = sys.argv.index("--pkg-info")
|
||||
pkg = sys.argv[idx + 1]
|
||||
import importlib
|
||||
mod = importlib.import_module(pkg)
|
||||
version = None
|
||||
try:
|
||||
import importlib.metadata as md
|
||||
try:
|
||||
version = md.version(pkg)
|
||||
except Exception:
|
||||
version = None
|
||||
except Exception:
|
||||
pass
|
||||
if version is None:
|
||||
version = getattr(mod, "__version__", None)
|
||||
dist_info_dir = None
|
||||
try:
|
||||
import importlib.metadata as md
|
||||
dist = md.distribution(pkg)
|
||||
files = list(dist.files or [])
|
||||
for f in files:
|
||||
if str(f).endswith("METADATA"):
|
||||
dist_info_dir = os.fspath(dist.locate_file(f))
|
||||
dist_info_dir = os.path.dirname(dist_info_dir)
|
||||
break
|
||||
except Exception:
|
||||
dist_info_dir = None
|
||||
out = {
|
||||
"package": pkg,
|
||||
"version": version,
|
||||
"module_file": getattr(mod, "__file__", None),
|
||||
"dist_info": dist_info_dir,
|
||||
}
|
||||
print(json.dumps(out, indent=2))
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(2)
|
||||
|
||||
# Diagnostic: verify MLX stack imports cleanly
|
||||
if "--check-mlx-stack" in sys.argv:
|
||||
out = {"ok": False, "mlx": None, "mlx_lm": None, "error": None}
|
||||
try:
|
||||
import importlib
|
||||
mlx = importlib.import_module("mlx")
|
||||
out["mlx"] = {
|
||||
"version": getattr(mlx, "__version__", None),
|
||||
"file": getattr(mlx, "__file__", None),
|
||||
}
|
||||
try:
|
||||
mlx_lm = importlib.import_module("mlx_lm")
|
||||
out["mlx_lm"] = {
|
||||
"version": getattr(mlx_lm, "__version__", None),
|
||||
"file": getattr(mlx_lm, "__file__", None),
|
||||
}
|
||||
except Exception as e:
|
||||
out["error"] = f"import mlx_lm failed: {e}"
|
||||
print(json.dumps(out, indent=2))
|
||||
sys.exit(2)
|
||||
out["ok"] = True
|
||||
print(json.dumps(out, indent=2))
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
out["error"] = str(e)
|
||||
print(json.dumps(out, indent=2))
|
||||
sys.exit(2)
|
||||
|
||||
# Child mode: run the server in-process (bypasses CLI parsing)
|
||||
if os.environ.get("MLXK2_CHILD_SERVER") == "1":
|
||||
from mlxk2.core.server_base import run_server as _run_server
|
||||
|
||||
host = os.environ.get("MLXK2_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("MLXK2_PORT", "8000"))
|
||||
log_level = os.environ.get("MLXK2_LOG_LEVEL", "info")
|
||||
reload = _to_bool(os.environ.get("MLXK2_RELOAD", "0"))
|
||||
_mt = os.environ.get("MLXK2_MAX_TOKENS", "")
|
||||
max_tokens = int(_mt) if _mt.strip() else None
|
||||
|
||||
_run_server(host=host, port=port, max_tokens=max_tokens, reload=reload, log_level=log_level)
|
||||
sys.exit(0)
|
||||
|
||||
# Parent mode: patch start_server to supervise a child process
|
||||
from mlxk2.core.server_base import run_server as _run_server
|
||||
import mlxk2.operations.serve as _serve
|
||||
|
||||
|
||||
def _run_supervised_server(*, host: str, port: int, log_level: str, reload: bool, max_tokens):
|
||||
env = os.environ.copy()
|
||||
env["MLXK2_CHILD_SERVER"] = "1"
|
||||
env["MLXK2_HOST"] = host
|
||||
env["MLXK2_PORT"] = str(port)
|
||||
env["MLXK2_LOG_LEVEL"] = log_level
|
||||
env["MLXK2_RELOAD"] = "1" if reload else "0"
|
||||
if max_tokens is not None:
|
||||
env["MLXK2_MAX_TOKENS"] = str(max_tokens)
|
||||
|
||||
proc = subprocess.Popen([sys.executable], start_new_session=True, env=env)
|
||||
|
||||
shutting_down = {"done": False}
|
||||
|
||||
def _graceful_shutdown(_sig=None, _frame=None):
|
||||
if shutting_down["done"]:
|
||||
return
|
||||
shutting_down["done"] = True
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
ret = proc.poll()
|
||||
if ret is not None:
|
||||
return
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGINT, _graceful_shutdown)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _graceful_shutdown)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return proc.wait()
|
||||
except KeyboardInterrupt:
|
||||
previous = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
try:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
ret = proc.poll()
|
||||
if ret is not None:
|
||||
return ret
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
while True:
|
||||
ret = proc.poll()
|
||||
if ret is not None:
|
||||
return ret
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
try:
|
||||
signal.signal(signal.SIGINT, previous)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _patched_start_server(*, model=None, port=8000, host="127.0.0.1", max_tokens=None,
|
||||
reload=False, log_level="info", verbose=False, supervise=True):
|
||||
return _run_server(host=host, port=port, max_tokens=max_tokens, reload=reload, log_level=log_level)
|
||||
|
||||
|
||||
_serve.start_server = _patched_start_server
|
||||
|
||||
if __name__ == "__main__":
|
||||
from mlxk2.cli import main
|
||||
main()
|
||||
PY
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Build with PyInstaller
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "[3/3] Building and packaging..."
|
||||
PYI_OPTS=(
|
||||
--clean
|
||||
--noconfirm
|
||||
--name "$APP_NAME"
|
||||
--log-level=WARN
|
||||
--specpath "$SPEC_DIR"
|
||||
--collect-all mlx
|
||||
--collect-all mlx_lm
|
||||
--collect-all huggingface_hub
|
||||
--collect-all requests
|
||||
--collect-all fastapi
|
||||
--collect-all pydantic
|
||||
--collect-all uvicorn
|
||||
)
|
||||
|
||||
if [[ "${ONEFILE:-0}" == "1" ]]; then
|
||||
PYI_OPTS+=(--onefile)
|
||||
else
|
||||
PYI_OPTS+=(--onedir)
|
||||
fi
|
||||
|
||||
pyinstaller "${PYI_OPTS[@]}" "$ENTRY_SCRIPT"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Package archive
|
||||
# -----------------------------------------------------------------------------
|
||||
TAR_BASE="$APP_NAME"
|
||||
if [[ -n "${VERSION:-}" ]]; then
|
||||
TAR_BASE="$TAR_BASE-${VERSION}"
|
||||
fi
|
||||
TAR_NAME="${ARCHIVE_NAME:-${TAR_BASE}.tgz}"
|
||||
|
||||
PACK_DIR="$ROOT_DIR/.pack-tmp"
|
||||
rm -rf "$PACK_DIR"
|
||||
mkdir -p "$PACK_DIR/$APP_NAME"
|
||||
|
||||
if [[ "${ONEFILE:-0}" == "1" ]]; then
|
||||
cp "$DIST_DIR/$APP_NAME" "$PACK_DIR/$APP_NAME/$APP_NAME"
|
||||
else
|
||||
rsync -a --delete "$DIST_DIR/$APP_NAME/" "$PACK_DIR/$APP_NAME/"
|
||||
fi
|
||||
|
||||
cp -f LICENSE "$PACK_DIR/$APP_NAME/" 2>/dev/null || true
|
||||
|
||||
tar -C "$PACK_DIR" -czf "$ART_DIR/$TAR_NAME" "$APP_NAME"
|
||||
echo "Created: $ART_DIR/$TAR_NAME"
|
||||
|
||||
echo "Build complete. Binaries are in dist/, archive in artifacts/."
|
||||
|
||||
rm -f "$ENTRY_SCRIPT"
|
||||
Executable
+457
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build a "no-PyInstaller" distribution that runs against an external Python
|
||||
# (e.g., the interpreter embedded in your Electron app at
|
||||
# MyApp.app/Contents/Resources/python/bin/python3).
|
||||
#
|
||||
# The script vendors dependencies into dist-ext-python/<BIN_NAME>/_vendor and
|
||||
# generates a launcher that executes: <python> -m mlxk2.cli ... with PYTHONPATH
|
||||
# pointed at that vendor directory.
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Temporary version override (VERSION env)
|
||||
# -----------------------------------------------------------------------------
|
||||
ORIG_VERSION="$(python3 - <<'PY'
|
||||
import pathlib, re
|
||||
text = pathlib.Path("mlxk2/__init__.py").read_text()
|
||||
m = re.search(r'__version__\s*=\s*"([^"]+)"', text)
|
||||
print(m.group(1) if m else "0.0.0")
|
||||
PY
|
||||
)"
|
||||
TARGET_VERSION="${VERSION:-$ORIG_VERSION}"
|
||||
TMP_INIT=""
|
||||
TMP_README=""
|
||||
RESTORE_VERSION=0
|
||||
APPLIED_PATCHES=()
|
||||
|
||||
cleanup_version() {
|
||||
if [[ "$RESTORE_VERSION" == "1" ]]; then
|
||||
if [[ -n "$TMP_INIT" && -f "$TMP_INIT" ]]; then
|
||||
cp "$TMP_INIT" mlxk2/__init__.py
|
||||
rm -f "$TMP_INIT"
|
||||
fi
|
||||
if [[ -n "$TMP_README" && -f "$TMP_README" ]]; then
|
||||
cp "$TMP_README" README.md
|
||||
rm -f "$TMP_README"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
cleanup_patches() {
|
||||
if [[ "${#APPLIED_PATCHES[@]}" -gt 0 ]]; then
|
||||
for pf in "${APPLIED_PATCHES[@]}"; do
|
||||
patch -l -p1 -R < "$pf" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
}
|
||||
cleanup_all() {
|
||||
cleanup_version
|
||||
cleanup_patches
|
||||
}
|
||||
trap cleanup_all EXIT
|
||||
|
||||
if [[ "$TARGET_VERSION" != "$ORIG_VERSION" ]]; then
|
||||
echo "[version] Updating project version: $ORIG_VERSION -> $TARGET_VERSION"
|
||||
TMP_INIT="$(mktemp)"
|
||||
cp mlxk2/__init__.py "$TMP_INIT"
|
||||
if [[ -f README.md ]]; then
|
||||
TMP_README="$(mktemp)"
|
||||
cp README.md "$TMP_README"
|
||||
fi
|
||||
RESTORE_VERSION=1
|
||||
python3 - "$ORIG_VERSION" "$TARGET_VERSION" <<'PY'
|
||||
import sys
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
old, new = sys.argv[1:3]
|
||||
|
||||
def tag(ver: str) -> str:
|
||||
if 'b' in ver:
|
||||
base, beta = ver.split('b', 1)
|
||||
if beta and beta.isdigit():
|
||||
return f"v{base}-beta.{beta}"
|
||||
return f"v{ver}"
|
||||
|
||||
init_path = pathlib.Path("mlxk2/__init__.py")
|
||||
text = init_path.read_text()
|
||||
text = re.sub(r'__version__\s*=\s*".*?"', f'__version__ = "{new}"', text)
|
||||
init_path.write_text(text)
|
||||
|
||||
readme_path = pathlib.Path("README.md")
|
||||
if readme_path.exists():
|
||||
readme = readme_path.read_text()
|
||||
readme = readme.replace(old, new)
|
||||
readme = readme.replace(tag(old), tag(new))
|
||||
readme_path.write_text(readme)
|
||||
PY
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Apply local CLI patch (idempotent)
|
||||
# -----------------------------------------------------------------------------
|
||||
apply_patch_with_cleanup() {
|
||||
local patch_file="$1"
|
||||
[[ -f "$patch_file" ]] || return
|
||||
if patch --dry-run -l -p1 < "$patch_file" >/dev/null 2>&1; then
|
||||
echo "[patch] Applying patch from $patch_file"
|
||||
patch -l -p1 < "$patch_file"
|
||||
APPLIED_PATCHES+=("$patch_file")
|
||||
elif patch --dry-run -R -l -p1 < "$patch_file" >/dev/null 2>&1; then
|
||||
echo "[patch] Patch already applied; skipping ($patch_file)"
|
||||
else
|
||||
echo "ERROR: Failed to apply patch: $patch_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/cli.patch"
|
||||
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/server_models.patch"
|
||||
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/server_streaming_usage.patch"
|
||||
apply_patch_with_cleanup "$ROOT_DIR/scripts/patches/runner_decode.patch"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# CLI flags (custom wheels)
|
||||
# -----------------------------------------------------------------------------
|
||||
MLX_WHEEL_ARG=""
|
||||
MLX_LM_WHEEL_ARG=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mlx-wheel)
|
||||
shift
|
||||
MLX_WHEEL_ARG="${1:-}"
|
||||
shift || true
|
||||
;;
|
||||
--mlx-lm-wheel)
|
||||
shift
|
||||
MLX_LM_WHEEL_ARG="${1:-}"
|
||||
shift || true
|
||||
;;
|
||||
*)
|
||||
echo "Unknown flag: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Output layout
|
||||
# -----------------------------------------------------------------------------
|
||||
APP_NAME="${BIN_NAME:-${APP_NAME:-msty-mlx-studio}}"
|
||||
OUT_ROOT="${DIST_DIR:-dist-ext-python}"
|
||||
OUT_DIR="$OUT_ROOT/$APP_NAME"
|
||||
VENDOR_DIR="$OUT_DIR/_vendor"
|
||||
ART_DIR="$ROOT_DIR/artifacts"
|
||||
mkdir -p "$ART_DIR"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Resolve Python interpreter used for installs
|
||||
# -----------------------------------------------------------------------------
|
||||
if [[ -n "${EMBEDDED_PYTHON:-}" ]]; then
|
||||
PY="$EMBEDDED_PYTHON"
|
||||
echo "Using EMBEDDED_PYTHON: $PY"
|
||||
elif [[ -x "$ROOT_DIR/python/bin/python3" ]]; then
|
||||
PY="$ROOT_DIR/python/bin/python3"
|
||||
echo "Using repo-local Python: $PY"
|
||||
else
|
||||
PY="$(command -v python3)"
|
||||
echo "WARNING: Falling back to system python3: $PY" >&2
|
||||
fi
|
||||
|
||||
if [[ ! -x "$PY" ]]; then
|
||||
echo "ERROR: Python interpreter not executable: $PY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Prepare output (always clean)
|
||||
# -----------------------------------------------------------------------------
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$VENDOR_DIR"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Custom wheel resolution
|
||||
# -----------------------------------------------------------------------------
|
||||
CUSTOM_MLX_WHEEL="${MLX_WHEEL:-}"
|
||||
if [[ -z "$CUSTOM_MLX_WHEEL" && -n "$MLX_WHEEL_ARG" ]]; then
|
||||
CUSTOM_MLX_WHEEL="$MLX_WHEEL_ARG"
|
||||
fi
|
||||
if [[ -z "$CUSTOM_MLX_WHEEL" && -f "$ROOT_DIR/scripts/mlx-custom.whl" ]]; then
|
||||
CUSTOM_MLX_WHEEL="$ROOT_DIR/scripts/mlx-custom.whl"
|
||||
fi
|
||||
if [[ -n "$CUSTOM_MLX_WHEEL" ]]; then
|
||||
echo "[opt] Using custom MLX wheel: $CUSTOM_MLX_WHEEL"
|
||||
[[ -f "$CUSTOM_MLX_WHEEL" ]] || { echo "ERROR: MLX wheel not found: $CUSTOM_MLX_WHEEL" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
CUSTOM_MLX_LM_WHEEL="${MLX_LM_WHEEL:-}"
|
||||
if [[ -z "$CUSTOM_MLX_LM_WHEEL" && -n "$MLX_LM_WHEEL_ARG" ]]; then
|
||||
CUSTOM_MLX_LM_WHEEL="$MLX_LM_WHEEL_ARG"
|
||||
fi
|
||||
if [[ -z "$CUSTOM_MLX_LM_WHEEL" && -f "$ROOT_DIR/scripts/mlx-lm-custom.whl" ]]; then
|
||||
CUSTOM_MLX_LM_WHEEL="$ROOT_DIR/scripts/mlx-lm-custom.whl"
|
||||
fi
|
||||
if [[ -n "$CUSTOM_MLX_LM_WHEEL" ]]; then
|
||||
echo "[opt] Using custom MLX-LM wheel: $CUSTOM_MLX_LM_WHEEL"
|
||||
[[ -f "$CUSTOM_MLX_LM_WHEEL" ]] || { echo "ERROR: MLX-LM wheel not found: $CUSTOM_MLX_LM_WHEEL" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Vendoring strategy
|
||||
# -----------------------------------------------------------------------------
|
||||
if [[ "${VENDOR_VIA_WHEELS:-0}" == "1" ]]; then
|
||||
echo "[1/3] Vendoring via wheels (does not execute embedded Python)"
|
||||
SYS_PY="$(command -v python3)"
|
||||
if [[ -z "$SYS_PY" ]]; then
|
||||
echo "ERROR: python3 not found on PATH for wheel download" >&2
|
||||
exit 1
|
||||
fi
|
||||
: "${TARGET_PY_VERSION:?Set TARGET_PY_VERSION (e.g., 3.12)}"
|
||||
TARGET_PLATFORM="${TARGET_PLATFORM:-macosx_11_0_arm64}"
|
||||
ver_nodot="${TARGET_PY_VERSION/.}" # 3.12 -> 312
|
||||
TARGET_ABI="${TARGET_ABI:-cp${ver_nodot}}"
|
||||
WHEELS_DIR="$OUT_DIR/_wheels"
|
||||
rm -rf "$WHEELS_DIR"
|
||||
mkdir -p "$WHEELS_DIR"
|
||||
|
||||
echo "[1a] Downloading wheels for Python ${TARGET_PY_VERSION} (${TARGET_ABI}) platform ${TARGET_PLATFORM}..."
|
||||
REQ_FILE="requirements.txt"
|
||||
if [[ ( -n "$CUSTOM_MLX_WHEEL" || -n "$CUSTOM_MLX_LM_WHEEL" ) && -f requirements.txt ]]; then
|
||||
REQ_TMP="$(mktemp)"
|
||||
awk 'BEGIN{IGNORECASE=0} {
|
||||
line=$0; trimmed=line; gsub(/^[ \t]+/,"",trimmed);
|
||||
if (trimmed ~ /^#/ || trimmed ~ /^$/) { print line; next }
|
||||
if (ENVIRON["FILTER_MLX"] && trimmed ~ /^mlx([ \t]|[><=]|$)/) { next }
|
||||
if (ENVIRON["FILTER_MLX_LM"] && trimmed ~ /^mlx-lm([ \t]|[><=]|$)/) { next }
|
||||
print line
|
||||
}' FILTER_MLX=$([[ -n "$CUSTOM_MLX_WHEEL" ]] && echo 1) FILTER_MLX_LM=$([[ -n "$CUSTOM_MLX_LM_WHEEL" ]] && echo 1) requirements.txt > "$REQ_TMP"
|
||||
REQ_FILE="$REQ_TMP"
|
||||
fi
|
||||
|
||||
if [[ -f "$REQ_FILE" ]]; then
|
||||
"$SYS_PY" -m pip download -r "$REQ_FILE" -d "$WHEELS_DIR" \
|
||||
--only-binary=:all: --implementation cp --platform "$TARGET_PLATFORM" \
|
||||
--python-version "${TARGET_PY_VERSION}" --abi "$TARGET_ABI" ${PIP_ARGS:-}
|
||||
fi
|
||||
if [[ "${REQ_FILE}" != "requirements.txt" ]]; then
|
||||
rm -f "$REQ_FILE"
|
||||
fi
|
||||
|
||||
[[ -z "$CUSTOM_MLX_WHEEL" ]] || cp "$CUSTOM_MLX_WHEEL" "$WHEELS_DIR/"
|
||||
[[ -z "$CUSTOM_MLX_LM_WHEEL" ]] || cp "$CUSTOM_MLX_LM_WHEEL" "$WHEELS_DIR/"
|
||||
|
||||
echo "[1b] Building wheel for local package..."
|
||||
"$SYS_PY" -m pip wheel -w "$WHEELS_DIR" . ${PIP_ARGS:-}
|
||||
|
||||
echo "[2/3] Unpacking wheels into vendor..."
|
||||
shopt -s nullglob
|
||||
for whl in "$WHEELS_DIR"/*.whl; do
|
||||
unzip -oq "$whl" -d "$VENDOR_DIR"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
if grep -Eqi '^\s*mlx([>=<]|\s|$)' "$ROOT_DIR/requirements.txt" 2>/dev/null; then
|
||||
if ! ls "$WHEELS_DIR"/mlx-*.whl >/dev/null 2>&1; then
|
||||
echo >&2
|
||||
echo "ERROR: No mlx wheel was downloaded for TARGET_PY_VERSION=${TARGET_PY_VERSION}." >&2
|
||||
echo "MLX publishes wheels only for select Python versions (typically 3.11/3.12)." >&2
|
||||
echo "Use TARGET_PY_VERSION=3.12 (or 3.11) or disable VENDOR_VIA_WHEELS to build from source." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "[1/3] Installing/Upgrading pip in external Python..."
|
||||
"$PY" - <<'PY'
|
||||
import ensurepip, sys
|
||||
try:
|
||||
ensurepip.bootstrap()
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
"$PY" -m pip install --upgrade pip setuptools wheel ${PIP_ARGS:-}
|
||||
|
||||
echo "[2/3] Installing project dependencies into: $VENDOR_DIR"
|
||||
if [[ -f requirements.txt ]]; then
|
||||
if [[ -n "$CUSTOM_MLX_WHEEL" || -n "$CUSTOM_MLX_LM_WHEEL" ]]; then
|
||||
REQ_TMP="$(mktemp)"
|
||||
awk 'BEGIN{IGNORECASE=0} {
|
||||
line=$0; trimmed=line; gsub(/^[ \t]+/,"",trimmed);
|
||||
if (trimmed ~ /^#/ || trimmed ~ /^$/) { print line; next }
|
||||
if (ENVIRON["FILTER_MLX"] && trimmed ~ /^mlx([ \t]|[><=]|$)/) { next }
|
||||
if (ENVIRON["FILTER_MLX_LM"] && trimmed ~ /^mlx-lm([ \t]|[><=]|$)/) { next }
|
||||
print line
|
||||
}' FILTER_MLX=$([[ -n "$CUSTOM_MLX_WHEEL" ]] && echo 1) FILTER_MLX_LM=$([[ -n "$CUSTOM_MLX_LM_WHEEL" ]] && echo 1) requirements.txt > "$REQ_TMP"
|
||||
"$PY" -m pip install --target "$VENDOR_DIR" -r "$REQ_TMP" ${PIP_ARGS:-}
|
||||
rm -f "$REQ_TMP"
|
||||
else
|
||||
"$PY" -m pip install --target "$VENDOR_DIR" -r requirements.txt ${PIP_ARGS:-}
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[2/3b] Installing mlxk2 package into vendor..."
|
||||
"$PY" -m pip install --upgrade --force-reinstall --target "$VENDOR_DIR" . ${PIP_ARGS:-}
|
||||
|
||||
if [[ -n "$CUSTOM_MLX_WHEEL" ]]; then
|
||||
echo "[opt] Installing custom MLX wheel into vendor..."
|
||||
"$PY" -m pip install --target "$VENDOR_DIR" --force-reinstall "$CUSTOM_MLX_WHEEL" ${PIP_ARGS:-}
|
||||
fi
|
||||
if [[ -n "$CUSTOM_MLX_LM_WHEEL" ]]; then
|
||||
echo "[opt] Installing custom MLX-LM wheel into vendor..."
|
||||
"$PY" -m pip install --target "$VENDOR_DIR" --force-reinstall "$CUSTOM_MLX_LM_WHEEL" ${PIP_ARGS:-}
|
||||
fi
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Launcher
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "[3/3] Writing launcher: $OUT_DIR/$APP_NAME"
|
||||
cat > "$OUT_DIR/$APP_NAME" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENDOR_DIR="$SELF_DIR/_vendor"
|
||||
|
||||
PY="${MLXK_PYTHON:-}"
|
||||
if [[ -z "$PY" && -n "${RESOURCES_PATH:-}" && -x "$RESOURCES_PATH/python/bin/python3" ]]; then
|
||||
PY="$RESOURCES_PATH/python/bin/python3"
|
||||
fi
|
||||
if [[ -z "$PY" && -x "$SELF_DIR/python/bin/python3" ]]; then
|
||||
PY="$SELF_DIR/python/bin/python3"
|
||||
fi
|
||||
if [[ -z "$PY" && -x "$SELF_DIR/../python/bin/python3" ]]; then
|
||||
PY="$SELF_DIR/../python/bin/python3"
|
||||
fi
|
||||
if [[ -z "$PY" ]]; then
|
||||
PY="$(command -v python3)"
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--python-info" ]]; then
|
||||
shift || true
|
||||
"$PY" - <<'PY'
|
||||
import sys, json
|
||||
print(json.dumps({
|
||||
"python_version": sys.version.split(" (", 1)[0],
|
||||
"executable": sys.executable,
|
||||
"prefix": sys.prefix,
|
||||
"base_prefix": getattr(sys, "base_prefix", None),
|
||||
"platform": sys.platform
|
||||
}, indent=2))
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--mlx-info" || "${1:-}" == "--pkg-info" ]]; then
|
||||
cmd="$1"; shift || true
|
||||
pkg="mlx"
|
||||
if [[ "$cmd" == "--pkg-info" ]]; then
|
||||
pkg="${1:-mlx}"; shift || true
|
||||
fi
|
||||
"$PY" - <<PY
|
||||
import json, importlib, sys, os
|
||||
pkg = ${pkg@P}
|
||||
try:
|
||||
mod = importlib.import_module(pkg)
|
||||
version = None
|
||||
try:
|
||||
import importlib.metadata as md
|
||||
try:
|
||||
version = md.version(pkg)
|
||||
except Exception:
|
||||
version = None
|
||||
except Exception:
|
||||
pass
|
||||
if version is None:
|
||||
version = getattr(mod, "__version__", None)
|
||||
dist_info_dir = None
|
||||
try:
|
||||
import importlib.metadata as md
|
||||
dist = md.distribution(pkg)
|
||||
files = list(dist.files or [])
|
||||
for f in files:
|
||||
if str(f).endswith("METADATA"):
|
||||
dist_info_dir = os.fspath(dist.locate_file(f))
|
||||
dist_info_dir = os.path.dirname(dist_info_dir)
|
||||
break
|
||||
except Exception:
|
||||
dist_info_dir = None
|
||||
print(json.dumps({
|
||||
"package": pkg,
|
||||
"version": version,
|
||||
"module_file": getattr(mod, "__file__", None),
|
||||
"dist_info": dist_info_dir,
|
||||
}, indent=2))
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(2)
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--check-mlx-stack" ]]; then
|
||||
"$PY" - <<'PY'
|
||||
import json
|
||||
out = {"ok": False, "mlx": None, "mlx_lm": None, "error": None}
|
||||
try:
|
||||
import importlib
|
||||
mlx = importlib.import_module("mlx")
|
||||
out["mlx"] = {
|
||||
"version": getattr(mlx, "__version__", None),
|
||||
"file": getattr(mlx, "__file__", None),
|
||||
}
|
||||
try:
|
||||
mlx_lm = importlib.import_module("mlx_lm")
|
||||
out["mlx_lm"] = {
|
||||
"version": getattr(mlx_lm, "__version__", None),
|
||||
"file": getattr(mlx_lm, "__file__", None),
|
||||
}
|
||||
except Exception as exc:
|
||||
out["error"] = f"import mlx_lm failed: {exc}"
|
||||
print(json.dumps(out, indent=2))
|
||||
raise SystemExit(2)
|
||||
out["ok"] = True
|
||||
print(json.dumps(out, indent=2))
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
print(json.dumps(out, indent=2))
|
||||
raise SystemExit(2)
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export PYTHONNOUSERSITE=1
|
||||
if [[ -n "${PYTHONPATH:-}" ]]; then
|
||||
export PYTHONPATH="$VENDOR_DIR:$PYTHONPATH"
|
||||
else
|
||||
export PYTHONPATH="$VENDOR_DIR"
|
||||
fi
|
||||
|
||||
exec "$PY" -m mlxk2.cli "$@"
|
||||
SH
|
||||
chmod +x "$OUT_DIR/$APP_NAME"
|
||||
|
||||
cp -f LICENSE "$OUT_DIR/" 2>/dev/null || true
|
||||
|
||||
echo "Built external-Python dist at: $OUT_DIR"
|
||||
echo "Quick test:"
|
||||
echo " $OUT_DIR/$APP_NAME --python-info"
|
||||
echo " $OUT_DIR/$APP_NAME --check-mlx-stack"
|
||||
echo " MLXK_PYTHON=\"/path/to/MyApp.app/Contents/Resources/python/bin/python3\" \\
|
||||
$OUT_DIR/$APP_NAME --version"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Package tarball for distribution (unless skipped)
|
||||
# -----------------------------------------------------------------------------
|
||||
if [[ "${SKIP_TAR:-0}" != "1" ]]; then
|
||||
TAR_BASE="$APP_NAME"
|
||||
if [[ -n "${VERSION:-}" ]]; then
|
||||
TAR_BASE="$TAR_BASE-${VERSION}"
|
||||
fi
|
||||
TAR_NAME="${ARCHIVE_NAME:-${TAR_BASE}.tgz}"
|
||||
echo "[package] Creating $ART_DIR/$TAR_NAME"
|
||||
tar -C "$OUT_ROOT" -czf "$ART_DIR/$TAR_NAME" "$APP_NAME"
|
||||
echo "Created: $ART_DIR/$TAR_NAME"
|
||||
fi
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Recursively remove macOS Gatekeeper quarantine from an embedded Python folder
|
||||
# and verify any leftovers (executables, .dylib, .so, etc.).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/clear_quarantine_python.sh /path/to/MyApp.app/Contents/Resources/python
|
||||
# # or, if your repo has ./python
|
||||
# scripts/clear_quarantine_python.sh
|
||||
#
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: $0 [python_folder]"
|
||||
echo "Example: $0 /Applications/MyApp.app/Contents/Resources/python"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PY_DIR="${1:-}"
|
||||
if [[ -z "$PY_DIR" ]]; then
|
||||
if [[ -d "python" ]]; then
|
||||
PY_DIR="python"
|
||||
else
|
||||
echo "ERROR: No python folder provided and ./python not found." >&2
|
||||
echo "Provide the path to your embedded Python folder." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -d "$PY_DIR" ]]; then
|
||||
echo "ERROR: Not a directory: $PY_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "WARNING: This script is intended for macOS (Darwin). Proceeding anyway..." >&2
|
||||
fi
|
||||
|
||||
echo "Target python folder: $PY_DIR"
|
||||
|
||||
echo "Scanning for quarantined items before..."
|
||||
before_list=$(mktemp)
|
||||
find "$PY_DIR" -type f -print0 \
|
||||
| xargs -0 -I{} sh -c 'xattr -p com.apple.quarantine "$1" >/dev/null 2>&1 && printf "%s\n" "$1"' -- {} \
|
||||
| tee "$before_list" >/dev/null || true
|
||||
before_count=$(wc -l < "$before_list" | tr -d ' ')
|
||||
echo "Quarantined files found: ${before_count}"
|
||||
|
||||
echo "Clearing quarantine recursively..."
|
||||
xattr -dr com.apple.quarantine "$PY_DIR" || true
|
||||
|
||||
echo "Re-scanning for leftovers..."
|
||||
after_list=$(mktemp)
|
||||
find "$PY_DIR" -type f -print0 \
|
||||
| xargs -0 -I{} sh -c 'xattr -p com.apple.quarantine "$1" >/dev/null 2>&1 && printf "%s\n" "$1"' -- {} \
|
||||
| tee "$after_list" >/dev/null || true
|
||||
after_count=$(wc -l < "$after_list" | tr -d ' ')
|
||||
|
||||
if [[ "$after_count" -eq 0 ]]; then
|
||||
echo "Success: no quarantined files remain under $PY_DIR."
|
||||
else
|
||||
echo "WARNING: ${after_count} quarantined files remain (showing up to 20):"
|
||||
head -n 20 "$after_list"
|
||||
echo "You may need elevated permissions or to remove quarantine on parent folders."
|
||||
fi
|
||||
|
||||
rm -f "$before_list" "$after_list"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
diff --git a/mlxk2/cli.py b/mlxk2/cli.py
|
||||
index c07ade8..24e4047 100644
|
||||
--- a/mlxk2/cli.py
|
||||
+++ b/mlxk2/cli.py
|
||||
@@ -447,6 +447,9 @@ def main():
|
||||
# Start server (this will run indefinitely)
|
||||
# Lazy import to avoid hard dependency on FastAPI/uvicorn at import time
|
||||
from .operations.serve import start_server
|
||||
+ # Supervision: allow disabling subprocess uvicorn via env (for embedded/bundled launchers)
|
||||
+ supervise_env = os.getenv("MLXK2_SUPERVISE", "1").strip().lower()
|
||||
+ supervise_flag = supervise_env not in ("0", "false", "no", "off")
|
||||
start_server(
|
||||
model=getattr(args, "model", None),
|
||||
port=args.port,
|
||||
@@ -455,7 +458,7 @@ def main():
|
||||
reload=getattr(args, "reload", False),
|
||||
log_level=getattr(args, "log_level", "info"),
|
||||
verbose=getattr(args, "verbose", False),
|
||||
- supervise=True
|
||||
+ supervise=supervise_flag
|
||||
)
|
||||
|
||||
# Should never reach here (server runs indefinitely)
|
||||
@@ -0,0 +1,57 @@
|
||||
diff --git a/mlxk2/core/runner/__init__.py b/mlxk2/core/runner/__init__.py
|
||||
index d22aacc..ca9ad06 100644
|
||||
--- a/mlxk2/core/runner/__init__.py
|
||||
+++ b/mlxk2/core/runner/__init__.py
|
||||
@@ -553,7 +553,7 @@ class MLXRunner:
|
||||
# Use sliding window for proper decoding
|
||||
start_idx = max(0, len(generated_tokens) - context_window)
|
||||
window_tokens = generated_tokens[start_idx:]
|
||||
- window_text = self._decode_tokens(window_tokens)
|
||||
+ window_text = self.tokenizer.decode(window_tokens)
|
||||
|
||||
# Extract new text
|
||||
if start_idx == 0:
|
||||
@@ -565,13 +565,13 @@ class MLXRunner:
|
||||
new_text = window_text
|
||||
previous_decoded = window_text
|
||||
else:
|
||||
- new_text = self._decode_tokens(window_tokens)
|
||||
+ new_text = self.tokenizer.decode(window_tokens)
|
||||
if len(window_tokens) > 1:
|
||||
- prefix = self._decode_tokens(window_tokens[:-1])
|
||||
+ prefix = self.tokenizer.decode(window_tokens[:-1])
|
||||
if new_text.startswith(prefix):
|
||||
new_text = new_text[len(prefix):]
|
||||
else:
|
||||
- new_text = self._decode_tokens([token_id])
|
||||
+ new_text = self.tokenizer.decode([token_id])
|
||||
|
||||
if new_text:
|
||||
accumulated_response += new_text
|
||||
@@ -750,7 +750,7 @@ class MLXRunner:
|
||||
# Decode full response using the streaming detokenizer
|
||||
# This properly converts BPE space markers (Ġ U+0120) to spaces (U+0020)
|
||||
# while tokenizer.decode() for slow tokenizers (LlamaTokenizer) does NOT.
|
||||
- full_response = self._decode_tokens(all_tokens)
|
||||
+ full_response = self.tokenizer.decode(all_tokens)
|
||||
|
||||
# Debug: Show raw generated tokens for quality analysis (enabled via --verbose)
|
||||
if self.verbose:
|
||||
@@ -762,7 +762,7 @@ class MLXRunner:
|
||||
for tid in last_3_ids:
|
||||
try:
|
||||
# Use detokenizer for debug output too
|
||||
- decoded = self._decode_tokens([tid])
|
||||
+ decoded = self.tokenizer.decode([tid])
|
||||
last_3_decoded.append(f"{tid}={decoded!r}")
|
||||
except Exception:
|
||||
last_3_decoded.append(f"{tid}=<error>")
|
||||
@@ -778,7 +778,7 @@ class MLXRunner:
|
||||
response = full_response[len(formatted_prompt):]
|
||||
else:
|
||||
# Decode generated tokens only (use detokenizer)
|
||||
- decoded = self._decode_tokens(generated_tokens)
|
||||
+ decoded = self.tokenizer.decode(generated_tokens)
|
||||
response = decoded if isinstance(decoded, str) else str(decoded)
|
||||
|
||||
# Filter stop tokens (strings only)
|
||||
@@ -0,0 +1,15 @@
|
||||
diff --git a/mlxk2/core/server_base.py b/mlxk2/core/server_base.py
|
||||
index be7db39..2835552 100644
|
||||
--- a/mlxk2/core/server_base.py
|
||||
+++ b/mlxk2/core/server_base.py
|
||||
@@ -790,9 +790,7 @@ async def list_models():
|
||||
# Use shared build_model_object (single source of truth)
|
||||
model_obj = build_model_object(model_name, model_dir, selected_path)
|
||||
|
||||
- # Filter: healthy AND runtime_compatible
|
||||
- if model_obj.get("health") != "healthy":
|
||||
- continue
|
||||
+ # Filter: runtime_compatible only (include unhealthy models for visibility)
|
||||
if not model_obj.get("runtime_compatible"):
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
--- a/mlxk2/core/server_base.py
|
||||
+++ b/mlxk2/core/server_base.py
|
||||
@@ -278,9 +278,12 @@
|
||||
|
||||
yield f"data: {json.dumps(initial_response)}\n\n"
|
||||
|
||||
+ # Track tokens for usage stats
|
||||
+ accumulated_text = ""
|
||||
+ prompt_tokens = count_tokens(prompt, runner.tokenizer)
|
||||
+
|
||||
# Stream tokens
|
||||
try:
|
||||
- token_count = 0
|
||||
for token in runner.generate_streaming(
|
||||
prompt=prompt,
|
||||
max_tokens=get_effective_max_tokens(runner, request.max_tokens, server_mode=True),
|
||||
@@ -292,7 +295,7 @@
|
||||
# Stop promptly if server is shutting down
|
||||
if _shutdown_event.is_set():
|
||||
raise KeyboardInterrupt()
|
||||
- token_count += 1
|
||||
+ accumulated_text += token
|
||||
|
||||
chunk_response = {
|
||||
"id": completion_id,
|
||||
@@ -364,9 +367,18 @@
|
||||
}
|
||||
yield f"data: {json.dumps(error_response)}\n\n"
|
||||
|
||||
- # Final response (skip if shutting down)
|
||||
+ # Final response with usage (skip if shutting down)
|
||||
if _shutdown_event.is_set():
|
||||
return
|
||||
+
|
||||
+ # Calculate completion tokens
|
||||
+ completion_tokens = count_tokens(accumulated_text, runner.tokenizer)
|
||||
+
|
||||
+ # Debug logging
|
||||
+ import os
|
||||
+ if os.environ.get("MLXK2_DEBUG"):
|
||||
+ print(f"[DEBUG] Sending final chunk with usage: prompt={prompt_tokens}, completion={completion_tokens}, total={prompt_tokens + completion_tokens}")
|
||||
+
|
||||
final_response = {
|
||||
"id": completion_id,
|
||||
"object": "text_completion",
|
||||
@@ -379,12 +391,16 @@
|
||||
"logprobs": None,
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
- ]
|
||||
+ ],
|
||||
+ "usage": {
|
||||
+ "prompt_tokens": prompt_tokens,
|
||||
+ "completion_tokens": completion_tokens,
|
||||
+ "total_tokens": prompt_tokens + completion_tokens
|
||||
+ }
|
||||
}
|
||||
|
||||
yield f"data: {json.dumps(final_response)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
-
|
||||
|
||||
|
||||
async def generate_chat_stream(
|
||||
@@ -419,6 +435,12 @@
|
||||
|
||||
yield f"data: {json.dumps(initial_response)}\n\n"
|
||||
|
||||
+ # Track tokens for usage stats
|
||||
+ accumulated_text = ""
|
||||
+ # Count prompt tokens from original messages
|
||||
+ total_prompt = "\n\n".join([msg.content for msg in messages])
|
||||
+ prompt_tokens = count_tokens(total_prompt, runner.tokenizer)
|
||||
+
|
||||
# Stream tokens
|
||||
try:
|
||||
for token in runner.generate_streaming(
|
||||
@@ -433,6 +455,8 @@
|
||||
# Stop promptly if server is shutting down
|
||||
if _shutdown_event.is_set():
|
||||
raise KeyboardInterrupt()
|
||||
+ accumulated_text += token
|
||||
+
|
||||
chunk_response = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
@@ -531,9 +555,18 @@
|
||||
}
|
||||
yield f"data: {json.dumps(error_response)}\n\n"
|
||||
|
||||
- # Final response (skip if shutting down)
|
||||
+ # Final response with usage (skip if shutting down)
|
||||
if _shutdown_event.is_set():
|
||||
return
|
||||
+
|
||||
+ # Calculate completion tokens
|
||||
+ completion_tokens = count_tokens(accumulated_text, runner.tokenizer)
|
||||
+
|
||||
+ # Debug logging
|
||||
+ import os
|
||||
+ if os.environ.get("MLXK2_DEBUG"):
|
||||
+ print(f"[DEBUG] Sending final chunk with usage: prompt={prompt_tokens}, completion={completion_tokens}, total={prompt_tokens + completion_tokens}")
|
||||
+
|
||||
final_response = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
@@ -545,12 +578,16 @@
|
||||
"delta": {},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
- ]
|
||||
+ ],
|
||||
+ "usage": {
|
||||
+ "prompt_tokens": prompt_tokens,
|
||||
+ "completion_tokens": completion_tokens,
|
||||
+ "total_tokens": prompt_tokens + completion_tokens
|
||||
+ }
|
||||
}
|
||||
|
||||
yield f"data: {json.dumps(final_response)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
-
|
||||
|
||||
|
||||
def format_chat_messages_for_runner(messages: List[ChatMessage]) -> List[Dict[str, str]]:
|
||||
@@ -596,8 +633,23 @@
|
||||
return 2048
|
||||
|
||||
|
||||
-def count_tokens(text: str) -> int:
|
||||
- """Rough token count estimation."""
|
||||
+def count_tokens(text: str, tokenizer=None) -> int:
|
||||
+ """Count tokens using tokenizer if available, otherwise estimate.
|
||||
+
|
||||
+ Args:
|
||||
+ text: Text to count tokens for
|
||||
+ tokenizer: Optional tokenizer for accurate counting
|
||||
+
|
||||
+ Returns:
|
||||
+ Token count
|
||||
+ """
|
||||
+ if tokenizer is not None:
|
||||
+ try:
|
||||
+ tokens = tokenizer.encode(text)
|
||||
+ return len(tokens) if isinstance(tokens, (list, tuple)) else 1
|
||||
+ except Exception:
|
||||
+ # Fallback to approximation if tokenizer fails
|
||||
+ pass
|
||||
return int(len(text.split()) * 1.3) # Approximation, convert to int
|
||||
|
||||
|
||||
@@ -865,8 +917,8 @@
|
||||
use_chat_template=False
|
||||
)
|
||||
|
||||
- prompt_tokens = count_tokens(prompt)
|
||||
- completion_tokens = count_tokens(generated_text)
|
||||
+ prompt_tokens = count_tokens(prompt, runner.tokenizer)
|
||||
+ completion_tokens = count_tokens(generated_text, runner.tokenizer)
|
||||
|
||||
return CompletionResponse(
|
||||
id=completion_id,
|
||||
@@ -980,8 +1032,8 @@
|
||||
repetition_penalty=request.repetition_penalty or 1.0,
|
||||
)
|
||||
|
||||
- prompt_tokens = count_tokens(prompt)
|
||||
- completion_tokens = count_tokens(generated_text)
|
||||
+ prompt_tokens = count_tokens(prompt, getattr(runner, 'tokenizer', None))
|
||||
+ completion_tokens = count_tokens(generated_text, getattr(runner, 'tokenizer', None))
|
||||
|
||||
# Graceful degradation: emulate SSE for stream=true
|
||||
if request.stream:
|
||||
@@ -1044,8 +1096,8 @@
|
||||
|
||||
# Token counting (handle both string and list content - use filtered messages)
|
||||
total_prompt = _extract_text_from_messages(messages)
|
||||
- prompt_tokens = count_tokens(total_prompt)
|
||||
- completion_tokens = count_tokens(generated_text)
|
||||
+ prompt_tokens = count_tokens(total_prompt, runner.tokenizer)
|
||||
+ completion_tokens = count_tokens(generated_text, runner.tokenizer)
|
||||
|
||||
return ChatCompletionResponse(
|
||||
id=completion_id,
|
||||
@@ -1155,8 +1207,8 @@
|
||||
)
|
||||
|
||||
# Token counting
|
||||
- prompt_tokens = count_tokens(prompt)
|
||||
- completion_tokens = count_tokens(generated_text)
|
||||
+ prompt_tokens = count_tokens(prompt, getattr(runner, 'tokenizer', None))
|
||||
+ completion_tokens = count_tokens(generated_text, getattr(runner, 'tokenizer', None))
|
||||
|
||||
# Graceful degradation: emulate SSE for stream=true
|
||||
if request.stream:
|
||||
Executable
+492
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# All-in-one release script: build (external or pyinstaller), sign, package, and notarize.
|
||||
#
|
||||
# Usage examples:
|
||||
# VERSION=2.0.0bp6 \
|
||||
# SIGN_ID="Developer ID Application: Your Name (TEAMID)" \
|
||||
# APPLE_ID="you@apple.com" APPLE_TEAM_ID="TEAMID" APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" \
|
||||
# scripts/release.sh --variant external --embedded-python /path/to/Resources/python/bin/python3
|
||||
#
|
||||
# # With custom wheels and custom name
|
||||
# VERSION=2.0.0bp6 BIN_NAME=my-cli \
|
||||
# SIGN_ID="$CSC_NAME" APPLE_ID=... APPLE_TEAM_ID=... APPLE_APP_SPECIFIC_PASSWORD=... \
|
||||
# scripts/release.sh --variant pyinstaller --mlx-wheel scripts/mlx-custom.whl --mlx-lm-wheel scripts/mlx-lm-custom.whl
|
||||
#
|
||||
# Flags:
|
||||
# --variant external|pyinstaller (default: external)
|
||||
# --embedded-python <path> (required for external variant)
|
||||
# --mlx-wheel <path> override MLX wheel
|
||||
# --mlx-lm-wheel <path> override MLX-LM wheel
|
||||
# --skip-notarize sign only; do not notarize
|
||||
# --entitlements <plist> optional entitlements for signing
|
||||
# --disable-libval add disable-library-validation entitlement
|
||||
#
|
||||
# Env:
|
||||
# VERSION, BIN_NAME version stamp and output name
|
||||
# MLX_WHEEL, MLX_LM_WHEEL alternative to flags
|
||||
# SIGN_ID or CSC_NAME codesign identity (Developer ID Application: … (TEAMID))
|
||||
# APPLE_ID, APPLE_TEAM_ID, APPLE_APP_SPECIFIC_PASSWORD for notarytool
|
||||
#
|
||||
|
||||
VARIANT=external
|
||||
EMBED_PY=""
|
||||
MLX_WHEEL_ARG=""
|
||||
MLX_LM_WHEEL_ARG=""
|
||||
SKIP_NOTARIZE=0
|
||||
ENTITLEMENTS=""
|
||||
DISABLE_LIBVAL=1
|
||||
CPYTHON_URL_ARG=""
|
||||
CPYTHON_DIR_ARG=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--variant) VARIANT="${2:-}"; shift 2;;
|
||||
--embedded-python) EMBED_PY="${2:-}"; shift 2;;
|
||||
--cpython-url) CPYTHON_URL_ARG="${2:-}"; shift 2;;
|
||||
--cpython-dir) CPYTHON_DIR_ARG="${2:-}"; shift 2;;
|
||||
--mlx-wheel) MLX_WHEEL_ARG="${2:-}"; shift 2;;
|
||||
--mlx-lm-wheel) MLX_LM_WHEEL_ARG="${2:-}"; shift 2;;
|
||||
--skip-notarize) SKIP_NOTARIZE=1; shift;;
|
||||
--entitlements) ENTITLEMENTS="${2:-}"; shift 2;;
|
||||
--disable-libval) DISABLE_LIBVAL=1; shift;;
|
||||
--no-disable-libval) DISABLE_LIBVAL=0; shift;;
|
||||
*) echo "Unknown flag: $1" >&2; exit 1;;
|
||||
esac
|
||||
done
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# Determine version: prefer env VERSION, otherwise package.json:version
|
||||
if [[ -z "${VERSION:-}" ]]; then
|
||||
PKG_VER="$(python3 - <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
with open('package.json') as f:
|
||||
print(json.load(f).get('version',''))
|
||||
except Exception:
|
||||
print('')
|
||||
PY
|
||||
)"
|
||||
if [[ -n "$PKG_VER" ]]; then
|
||||
export VERSION="$PKG_VER"
|
||||
echo "[version] Using package.json version: $VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
APP_NAME="${BIN_NAME:-${APP_NAME:-msty-mlx-studio}}"
|
||||
ART_DIR="$ROOT_DIR/artifacts"
|
||||
mkdir -p "$ART_DIR"
|
||||
|
||||
# Determine signing identity early (used for pre-signing embedded Python)
|
||||
SIGN_ID_EFFECTIVE="${SIGN_ID:-${CSC_NAME:-}}"
|
||||
|
||||
ENV_ASSIGN=()
|
||||
if [[ -n "${VERSION:-}" ]]; then ENV_ASSIGN+=(VERSION="$VERSION"); fi
|
||||
ENV_ASSIGN+=(BIN_NAME="$APP_NAME")
|
||||
if [[ -n "$MLX_WHEEL_ARG" ]]; then ENV_ASSIGN+=(MLX_WHEEL="$MLX_WHEEL_ARG"); fi
|
||||
if [[ -n "${MLX_WHEEL:-}" ]]; then ENV_ASSIGN+=(MLX_WHEEL="$MLX_WHEEL"); fi
|
||||
if [[ -n "$MLX_LM_WHEEL_ARG" ]]; then ENV_ASSIGN+=(MLX_LM_WHEEL="$MLX_LM_WHEEL_ARG"); fi
|
||||
if [[ -n "${MLX_LM_WHEEL:-}" ]]; then ENV_ASSIGN+=(MLX_LM_WHEEL="$MLX_LM_WHEEL"); fi
|
||||
|
||||
case "$VARIANT" in
|
||||
external)
|
||||
# Resolve CPython source if not explicitly provided
|
||||
CPYTHON_URL="${CPYTHON_URL_ARG:-${CPYTHON_URL:-}}"
|
||||
CPYTHON_DIR="${CPYTHON_DIR_ARG:-${CPYTHON_DIR:-${ROOT_DIR}/python}}"
|
||||
|
||||
if [[ -z "$EMBED_PY" ]]; then
|
||||
if [[ -n "$CPYTHON_URL" ]]; then
|
||||
echo "[fetch] Downloading CPython from: $CPYTHON_URL"
|
||||
TMP_TAR="$(mktemp -t cpython-XXXXXX).tar.gz"
|
||||
curl -L "$CPYTHON_URL" -o "$TMP_TAR"
|
||||
mkdir -p "$CPYTHON_DIR"
|
||||
# Extract; detect if payload contains a top-level 'python' folder
|
||||
TAR_TOP="$(tar -tzf "$TMP_TAR" | head -n1 | cut -d/ -f1)"
|
||||
tar -xzf "$TMP_TAR" -C "$CPYTHON_DIR" --strip-components=0
|
||||
rm -f "$TMP_TAR"
|
||||
# Common layouts: either extracted files under CPYTHON_DIR directly or nested python/*
|
||||
if [[ -x "$CPYTHON_DIR/bin/python3" ]]; then
|
||||
EMBED_PY="$CPYTHON_DIR/bin/python3"
|
||||
elif [[ -x "$CPYTHON_DIR/python/bin/python3" ]]; then
|
||||
EMBED_PY="$CPYTHON_DIR/python/bin/python3"
|
||||
else
|
||||
# Try to find any python3 in subtree
|
||||
EMBED_PY="$(find "$CPYTHON_DIR" -type f -path '*/bin/python3*' -perm -111 -print -quit 2>/dev/null || true)"
|
||||
fi
|
||||
[[ -n "$EMBED_PY" ]] || { echo "ERROR: Unable to find python3 in extracted CPython at $CPYTHON_DIR" >&2; exit 1; }
|
||||
echo "[fetch] Embedded Python resolved to: $EMBED_PY"
|
||||
elif [[ -x "$ROOT_DIR/python/bin/python3" ]]; then
|
||||
EMBED_PY="$ROOT_DIR/python/bin/python3"
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ -n "$EMBED_PY" ]] || { echo "ERROR: Embedded Python not found. Provide --embedded-python or CPYTHON_URL/CPYTHON_DIR or place it at ./python/bin/python3" >&2; exit 1; }
|
||||
echo "[build] External-Python variant"
|
||||
# Pre-sign embedded Python so we can execute it during build (and clear quarantine)
|
||||
PYSRC_DIR="$(cd "$(dirname "$EMBED_PY")/.." && pwd)"
|
||||
if [[ -d "$PYSRC_DIR" ]]; then
|
||||
echo "[prep] Clearing quarantine and signing embedded Python at: $PYSRC_DIR"
|
||||
dos2unix scripts/* || true
|
||||
scripts/clear_quarantine_python.sh "$PYSRC_DIR" || true
|
||||
PRE_SIGN_ARGS=("$PYSRC_DIR")
|
||||
if [[ "$DISABLE_LIBVAL" == "1" ]]; then PRE_SIGN_ARGS+=(--disable-libval); fi
|
||||
if [[ -n "$ENTITLEMENTS" ]]; then PRE_SIGN_ARGS+=(--entitlements "$ENTITLEMENTS"); fi
|
||||
if [[ -n "$SIGN_ID_EFFECTIVE" ]]; then SIGN_ID="$SIGN_ID_EFFECTIVE" scripts/sign_and_notarize.sh "${PRE_SIGN_ARGS[@]}"; else echo "[prep] SIGN_ID not set; skipping pre-sign (may still run locally if quarantine is clear)"; fi
|
||||
fi
|
||||
# Always clean external bundle output for release builds
|
||||
env "${ENV_ASSIGN[@]}" EMBEDDED_PYTHON="$EMBED_PY" CLEAN=1 SKIP_TAR=1 scripts/build_external_python.sh
|
||||
BUNDLE_DIR="$ROOT_DIR/dist-ext-python/$APP_NAME"
|
||||
|
||||
# Include the embedded Python inside the bundle so the launcher can auto-resolve SELF_DIR/python/bin/python3
|
||||
if [[ -d "$PYSRC_DIR" ]]; then
|
||||
echo "[bundle] Embedding Python into bundle at: $BUNDLE_DIR/python"
|
||||
rsync -a --delete "$PYSRC_DIR/" "$BUNDLE_DIR/python/"
|
||||
fi
|
||||
|
||||
# Replace shell launcher with a tiny Mach-O wrapper so the process name shows as "$APP_NAME"
|
||||
echo "[bundle] Building native launcher to keep process name as $APP_NAME"
|
||||
cat > "$BUNDLE_DIR/.launcher.c" <<'C'
|
||||
#include <spawn.h>
|
||||
#include <sys/wait.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <libgen.h>
|
||||
#include <limits.h>
|
||||
#include <unistd.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <signal.h>
|
||||
extern char **environ;
|
||||
|
||||
static volatile sig_atomic_t got_sig = 0;
|
||||
static volatile pid_t child_pid = -1;
|
||||
|
||||
static void dirname_of(const char *path, char *out, size_t outsz) {
|
||||
strncpy(out, path, outsz - 1); out[outsz-1] = '\0';
|
||||
char *d = dirname(out);
|
||||
if (d != out) strncpy(out, d, outsz - 1);
|
||||
}
|
||||
|
||||
static void handle_signal(int sig) {
|
||||
got_sig++;
|
||||
pid_t pid = child_pid;
|
||||
if (pid > 0) {
|
||||
if (got_sig == 1) kill(pid, sig == SIGINT ? SIGINT : SIGTERM);
|
||||
else kill(pid, SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
// Resolve executable path
|
||||
char execPath[PATH_MAX]; uint32_t size = sizeof(execPath);
|
||||
if (_NSGetExecutablePath(execPath, &size) != 0) return 127;
|
||||
char bundleRoot[PATH_MAX]; dirname_of(execPath, bundleRoot, sizeof(bundleRoot));
|
||||
|
||||
// Construct embedded python and vendor paths
|
||||
char pyPath[PATH_MAX]; snprintf(pyPath, sizeof(pyPath), "%s/python/bin/python3", bundleRoot);
|
||||
char vendorPath[PATH_MAX]; snprintf(vendorPath, sizeof(vendorPath), "%s/_vendor", bundleRoot);
|
||||
|
||||
// Prepare environment
|
||||
setenv("MLXK_PYTHON", pyPath, 1);
|
||||
setenv("RESOURCES_PATH", bundleRoot, 1);
|
||||
setenv("PYTHONNOUSERSITE", "1", 0);
|
||||
// Disable supervised uvicorn subprocess; run server in-process
|
||||
setenv("MLXK2_SUPERVISE", "0", 1);
|
||||
// Ensure any `python3` shebang or /usr/bin/env python3 resolves to embedded one
|
||||
const char *old_path = getenv("PATH");
|
||||
size_t newlen = strlen(bundleRoot) + strlen("/python/bin") + 1 + (old_path ? strlen(old_path) : 0) + 1;
|
||||
char *newpath = (char*)malloc(newlen);
|
||||
if (newpath) {
|
||||
if (old_path && *old_path)
|
||||
snprintf(newpath, newlen, "%s/python/bin:%s", bundleRoot, old_path);
|
||||
else
|
||||
snprintf(newpath, newlen, "%s/python/bin", bundleRoot);
|
||||
setenv("PATH", newpath, 1);
|
||||
free(newpath);
|
||||
}
|
||||
// Help Python find its stdlib explicitly
|
||||
char pyHome[PATH_MAX]; snprintf(pyHome, sizeof(pyHome), "%s/python", bundleRoot);
|
||||
setenv("PYTHONHOME", pyHome, 1);
|
||||
setenv("PYTHONEXECUTABLE", pyPath, 1);
|
||||
// Prepend vendor to PYTHONPATH
|
||||
const char *pp = getenv("PYTHONPATH");
|
||||
if (pp && *pp) {
|
||||
size_t len = strlen(vendorPath) + 1 + strlen(pp) + 1;
|
||||
char *buf = (char*)malloc(len);
|
||||
if (!buf) return 127;
|
||||
snprintf(buf, len, "%s:%s", vendorPath, pp);
|
||||
setenv("PYTHONPATH", buf, 1);
|
||||
free(buf);
|
||||
} else {
|
||||
setenv("PYTHONPATH", vendorPath, 1);
|
||||
}
|
||||
|
||||
// Build argv for python: python -m mlxk2.cli [args...]
|
||||
int n = argc + 3; // python, -m, module, args...
|
||||
char **pargv = (char**)calloc(n + 1, sizeof(char*));
|
||||
if (!pargv) return 127;
|
||||
pargv[0] = pyPath;
|
||||
pargv[1] = "-m";
|
||||
pargv[2] = "mlxk2.cli";
|
||||
for (int i = 1; i < argc; ++i) pargv[2 + i] = argv[i];
|
||||
pargv[n] = NULL;
|
||||
|
||||
// Install signal handlers to forward to child
|
||||
struct sigaction sa; memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = handle_signal; sigemptyset(&sa.sa_mask);
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
sigaction(SIGHUP, &sa, NULL);
|
||||
|
||||
// Spawn python child; keep this process name as launcher
|
||||
pid_t pid; int rc = posix_spawn(&pid, pyPath, NULL, NULL, pargv, environ);
|
||||
if (rc != 0) return rc;
|
||||
child_pid = pid;
|
||||
|
||||
// Wait and propagate exit
|
||||
int status = 0; while (1) {
|
||||
pid_t w = waitpid(pid, &status, 0);
|
||||
if (w == -1) continue; // interrupted by signal
|
||||
break;
|
||||
}
|
||||
if (WIFEXITED(status)) return WEXITSTATUS(status);
|
||||
if (WIFSIGNALED(status)) return 128 + WTERMSIG(status);
|
||||
return 0;
|
||||
}
|
||||
C
|
||||
clang -O2 -Wall -mmacosx-version-min=11 -o "$BUNDLE_DIR/.launcher" "$BUNDLE_DIR/.launcher.c"
|
||||
mv -f "$BUNDLE_DIR/.launcher" "$BUNDLE_DIR/$APP_NAME"
|
||||
rm -f "$BUNDLE_DIR/.launcher.c"
|
||||
;;
|
||||
pyinstaller)
|
||||
echo "[build] PyInstaller variant"
|
||||
# Clean old bundle output to avoid stale files across releases
|
||||
rm -rf "$ROOT_DIR/dist/$APP_NAME" "$ROOT_DIR/build/$APP_NAME"
|
||||
env "${ENV_ASSIGN[@]}" scripts/build.sh
|
||||
BUNDLE_DIR="$ROOT_DIR/dist/$APP_NAME"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown variant: $VARIANT" >&2; exit 1;
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -d "$BUNDLE_DIR" ]] || { echo "ERROR: bundle directory not found: $BUNDLE_DIR" >&2; exit 1; }
|
||||
|
||||
# Post-build version report: ensure bundled mlx + mlx-lm satisfy requirements.txt
|
||||
REQ_FILE="$ROOT_DIR/requirements.txt"
|
||||
if [[ -f "$REQ_FILE" ]]; then
|
||||
echo "[verify] MLX bundle version report"
|
||||
python3 - "$BUNDLE_DIR" "$REQ_FILE" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
bundle_dir = sys.argv[1]
|
||||
req_path = sys.argv[2]
|
||||
targets = {"mlx", "mlx-lm"}
|
||||
|
||||
Requirement = None
|
||||
try:
|
||||
from packaging.requirements import Requirement # type: ignore
|
||||
except Exception:
|
||||
try:
|
||||
from pip._vendor.packaging.requirements import Requirement # type: ignore
|
||||
except Exception:
|
||||
Requirement = None
|
||||
|
||||
|
||||
def find_metadata(root: str):
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
if "METADATA" in filenames:
|
||||
yield os.path.join(dirpath, "METADATA")
|
||||
|
||||
|
||||
def parse_metadata(path: str):
|
||||
name = None
|
||||
version = None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
if line.startswith("Name:"):
|
||||
name = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Version:"):
|
||||
version = line.split(":", 1)[1].strip()
|
||||
if name and version:
|
||||
break
|
||||
except Exception:
|
||||
return None, None
|
||||
return name, version
|
||||
|
||||
|
||||
search_root = os.path.join(bundle_dir, "_vendor")
|
||||
roots = [search_root] if os.path.isdir(search_root) else [bundle_dir]
|
||||
|
||||
found = {}
|
||||
duplicates = {}
|
||||
for root in roots:
|
||||
for meta in find_metadata(root):
|
||||
name, version = parse_metadata(meta)
|
||||
if not name or not version:
|
||||
continue
|
||||
key = name.lower()
|
||||
if key not in targets:
|
||||
continue
|
||||
if key in found and found[key]["version"] != version:
|
||||
duplicates.setdefault(key, []).append({"version": version, "metadata": meta})
|
||||
else:
|
||||
found.setdefault(key, {"version": version, "metadata": meta})
|
||||
|
||||
|
||||
def parse_requirements(path: str):
|
||||
reqs = {}
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
for raw in fh:
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(("-r", "--")):
|
||||
continue
|
||||
if Requirement is not None:
|
||||
try:
|
||||
req = Requirement(line)
|
||||
except Exception:
|
||||
continue
|
||||
name = req.name.lower()
|
||||
if name in targets:
|
||||
if req.marker is not None and not req.marker.evaluate():
|
||||
continue
|
||||
reqs[name] = {"raw": line, "specifier": req.specifier}
|
||||
else:
|
||||
match = re.match(r"([A-Za-z0-9_.-]+)(.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group(1).lower()
|
||||
if name in targets:
|
||||
reqs[name] = {"raw": line, "specifier": match.group(2).strip()}
|
||||
return reqs
|
||||
|
||||
|
||||
def simple_satisfies(version: str, spec) -> bool | None:
|
||||
spec_str = str(spec or "").strip()
|
||||
if not spec_str:
|
||||
return None
|
||||
match = re.match(r"(==|>=|<=|<|>)(.+)", spec_str)
|
||||
if not match:
|
||||
return None
|
||||
op = match.group(1)
|
||||
req_ver = match.group(2).strip()
|
||||
|
||||
def split_nums(v: str):
|
||||
parts = []
|
||||
for part in re.split(r"[.+-]", v):
|
||||
if part.isdigit():
|
||||
parts.append(int(part))
|
||||
else:
|
||||
break
|
||||
return parts
|
||||
|
||||
a = split_nums(version)
|
||||
b = split_nums(req_ver)
|
||||
if not a or not b:
|
||||
return None
|
||||
n = max(len(a), len(b))
|
||||
a += [0] * (n - len(a))
|
||||
b += [0] * (n - len(b))
|
||||
if a < b:
|
||||
cmp = -1
|
||||
elif a > b:
|
||||
cmp = 1
|
||||
else:
|
||||
cmp = 0
|
||||
if op == "==":
|
||||
return cmp == 0
|
||||
if op == ">=":
|
||||
return cmp >= 0
|
||||
if op == "<=":
|
||||
return cmp <= 0
|
||||
if op == ">":
|
||||
return cmp > 0
|
||||
if op == "<":
|
||||
return cmp < 0
|
||||
return None
|
||||
|
||||
|
||||
reqs = parse_requirements(req_path)
|
||||
errors = []
|
||||
notes = []
|
||||
|
||||
root_label = roots[0]
|
||||
print(f" source: {root_label}")
|
||||
for name in sorted(targets):
|
||||
inst = found.get(name)
|
||||
req = reqs.get(name)
|
||||
inst_ver = inst["version"] if inst else "MISSING"
|
||||
req_spec = str(req["specifier"]) if req else "MISSING"
|
||||
print(f" - {name}: {inst_ver} (requirement: {req_spec})")
|
||||
if not inst:
|
||||
errors.append(f"{name} not found in bundle (searched {root_label})")
|
||||
continue
|
||||
if req and req.get("specifier"):
|
||||
ok = None
|
||||
if Requirement is not None:
|
||||
try:
|
||||
ok = req["specifier"].contains(inst["version"], prereleases=True)
|
||||
except Exception:
|
||||
ok = None
|
||||
if ok is None:
|
||||
ok = simple_satisfies(inst["version"], req["specifier"])
|
||||
if ok is False:
|
||||
errors.append(f"{name} {inst['version']} does not satisfy '{req_spec}'")
|
||||
elif ok is None:
|
||||
notes.append(f"{name}: unable to verify requirement '{req_spec}' (no packaging)")
|
||||
|
||||
if duplicates:
|
||||
for name, dups in duplicates.items():
|
||||
versions = ", ".join(sorted({d["version"] for d in dups}))
|
||||
errors.append(f"{name} multiple versions found: {versions}")
|
||||
|
||||
for note in notes:
|
||||
print(f" note: {note}")
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
print(f" error: {err}")
|
||||
sys.exit(1)
|
||||
PY
|
||||
else
|
||||
echo "[verify] requirements.txt not found; skipping MLX version report"
|
||||
fi
|
||||
|
||||
SIGN_ID_EFFECTIVE="${SIGN_ID:-${CSC_NAME:-}}"
|
||||
[[ -n "$SIGN_ID_EFFECTIVE" ]] || { echo "ERROR: set SIGN_ID or CSC_NAME to your codesigning identity" >&2; exit 1; }
|
||||
|
||||
SIGN_ARGS=("$BUNDLE_DIR")
|
||||
if [[ -n "$ENTITLEMENTS" ]]; then SIGN_ARGS+=(--entitlements "$ENTITLEMENTS"); fi
|
||||
if [[ "$DISABLE_LIBVAL" == "1" ]]; then SIGN_ARGS+=(--disable-libval); fi
|
||||
|
||||
echo "[sign] Codesigning bundle with: $SIGN_ID_EFFECTIVE"
|
||||
SIGN_ID="$SIGN_ID_EFFECTIVE" scripts/sign_and_notarize.sh "${SIGN_ARGS[@]}"
|
||||
|
||||
TGZ_NAME_BASE="$APP_NAME"; [[ -n "${VERSION:-}" ]] && TGZ_NAME_BASE+="-${VERSION}"
|
||||
TGZ_PATH="$ART_DIR/${TGZ_NAME_BASE}.tgz"
|
||||
|
||||
# Notarize using a temporary zip (Apple Notary Service prefers zip input).
|
||||
if [[ "$SKIP_NOTARIZE" == "0" ]]; then
|
||||
TMP_ZIP="$(mktemp -t ${APP_NAME}-notary-XXXXXX).zip"
|
||||
echo "[notary] Submitting for notarization via temporary zip"
|
||||
SIGN_ID="$SIGN_ID_EFFECTIVE" \
|
||||
APPLE_ID="${APPLE_ID:-}" APPLE_TEAM_ID="${APPLE_TEAM_ID:-}" APPLE_APP_SPECIFIC_PASSWORD="${APPLE_APP_SPECIFIC_PASSWORD:-}" \
|
||||
scripts/sign_and_notarize.sh "$BUNDLE_DIR" --notarize --zip "$TMP_ZIP"
|
||||
rm -f "$TMP_ZIP" || true
|
||||
fi
|
||||
|
||||
# Always produce TGZ for distribution
|
||||
echo "[package] Creating tgz: $TGZ_PATH"
|
||||
tar -C "$(dirname "$BUNDLE_DIR")" -czf "$TGZ_PATH" "$APP_NAME"
|
||||
|
||||
echo "[done] Release ready"
|
||||
echo "- Bundle: $BUNDLE_DIR"
|
||||
echo "- TGZ: $TGZ_PATH"
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Codesign all Mach-O binaries in a folder (or .app) and optionally notarize.
|
||||
#
|
||||
# Usage:
|
||||
# SIGN_ID="Developer ID Application: Your Name (TEAMID)" \
|
||||
# scripts/sign_and_notarize.sh <target-folder-or-app> [--notarize] [--zip <out.zip>] [--entitlements <plist>]
|
||||
#
|
||||
# Optional env:
|
||||
# KEYCHAIN_PROFILE=AC_PROFILE # notarytool keychain profile (xcrun notarytool store-credentials ...)
|
||||
#
|
||||
# Notes:
|
||||
# - For local dev only, you can ad-hoc sign with SIGN_ID="-" (won't pass Gatekeeper on other Macs).
|
||||
# - For proper distribution, use a real Developer ID Application cert and --notarize.
|
||||
|
||||
TARGET="${1:-}"
|
||||
[[ -n "$TARGET" ]] || { echo "ERROR: missing target (folder or .app)" >&2; exit 1; }
|
||||
shift || true
|
||||
|
||||
NOTARIZE=0
|
||||
ZIP_OUT=""
|
||||
ENTITLEMENTS=""
|
||||
RUNTIME=1
|
||||
DISABLE_LIBVAL=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--notarize) NOTARIZE=1; shift;;
|
||||
--zip) ZIP_OUT="${2:-}"; shift 2;;
|
||||
--entitlements) ENTITLEMENTS="${2:-}"; shift 2;;
|
||||
--no-runtime) RUNTIME=0; shift;;
|
||||
--disable-library-validation|--disable-libval) DISABLE_LIBVAL=1; shift;;
|
||||
*) echo "Unknown flag: $1" >&2; exit 1;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "${SIGN_ID:-}" ]] || { echo "ERROR: set SIGN_ID to your 'Developer ID Application: … (TEAMID)' or '-' for ad-hoc" >&2; exit 1; }
|
||||
|
||||
if [[ ! -e "$TARGET" ]]; then
|
||||
echo "ERROR: target not found: $TARGET" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[sign] Clearing quarantine (dev convenience)"
|
||||
xattr -dr com.apple.quarantine "$TARGET" || true
|
||||
|
||||
codesign_file() {
|
||||
local f="$1"
|
||||
if file -b "$f" | grep -Eq 'Mach-O|universal binary'; then
|
||||
local args=(--force --timestamp --sign "$SIGN_ID")
|
||||
if [[ "$RUNTIME" == "1" ]]; then
|
||||
args+=(--options runtime)
|
||||
fi
|
||||
if [[ -n "$ENTITLEMENTS" ]]; then
|
||||
args+=(--entitlements "$ENTITLEMENTS")
|
||||
fi
|
||||
codesign "${args[@]}" "$f" 2>/dev/null || {
|
||||
echo "[warn] codesign failed for $f; retrying verbose" >&2
|
||||
codesign "${args[@]}" --verbose "$f"
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$DISABLE_LIBVAL" == "1" && -z "$ENTITLEMENTS" ]]; then
|
||||
echo "[sign] Creating temporary entitlements with disable-library-validation"
|
||||
ENT_TMP="$(mktemp)"
|
||||
cat > "$ENT_TMP" <<'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
ENTITLEMENTS="$ENT_TMP"
|
||||
# Hardened runtime generally required when using this entitlement
|
||||
RUNTIME=1
|
||||
fi
|
||||
|
||||
echo "[sign] Walking files under: $TARGET"
|
||||
if [[ -d "$TARGET" ]]; then
|
||||
while IFS= read -r -d '' f; do
|
||||
codesign_file "$f"
|
||||
done < <(find "$TARGET" -type f -print0)
|
||||
else
|
||||
codesign_file "$TARGET"
|
||||
fi
|
||||
|
||||
# If an .app, sign the bundle itself (deep)
|
||||
if [[ "$TARGET" == *.app ]]; then
|
||||
echo "[sign] Deep-signing app bundle"
|
||||
args=(--force --timestamp --sign "$SIGN_ID" --deep)
|
||||
if [[ "$RUNTIME" == "1" ]]; then
|
||||
args+=(--options runtime)
|
||||
fi
|
||||
[[ -n "$ENTITLEMENTS" ]] && args+=(--entitlements "$ENTITLEMENTS")
|
||||
codesign "${args[@]}" "$TARGET"
|
||||
fi
|
||||
|
||||
echo "[verify] Sample verification"
|
||||
spctl -a -t exec -vv "$TARGET" || true
|
||||
|
||||
if [[ "$NOTARIZE" == "1" ]]; then
|
||||
ZIP_OUT=${ZIP_OUT:-"$(basename "$TARGET").zip"}
|
||||
echo "[zip] Creating: $ZIP_OUT"
|
||||
ditto -c -k --keepParent "$TARGET" "$ZIP_OUT"
|
||||
echo "[notary] Submitting to Apple Notary Service"
|
||||
if [[ -n "${KEYCHAIN_PROFILE:-}" ]]; then
|
||||
echo " using keychain profile: $KEYCHAIN_PROFILE"
|
||||
xcrun notarytool submit "$ZIP_OUT" --keychain-profile "$KEYCHAIN_PROFILE" --wait
|
||||
else
|
||||
[[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] || {
|
||||
echo "ERROR: provide KEYCHAIN_PROFILE or APPLE_ID, APPLE_TEAM_ID, APPLE_APP_SPECIFIC_PASSWORD" >&2; exit 1; }
|
||||
xcrun notarytool submit "$ZIP_OUT" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--wait
|
||||
fi
|
||||
# Staple if it's an .app
|
||||
if [[ "$TARGET" == *.app ]]; then
|
||||
echo "[staple] Stapling ticket to app"
|
||||
xcrun stapler staple "$TARGET" || true
|
||||
fi
|
||||
echo "[done] Notarization complete"
|
||||
fi
|
||||
|
||||
echo "[done] Codesigning finished"
|
||||
Reference in New Issue
Block a user