Add uv (Astral Python package manager) support

- Add uv package manager implementation following pip pattern
- Configure MITM proxy with CA bundle for PyPI packages
- Add shell integration (bash/zsh/fish/PowerShell)
- Conditional on --include-python flag
- Add 33 comprehensive E2E tests covering:
  - uv pip install/sync/compile commands
  - uv add for project dependencies
  - uv tool install for global tools
  - uv run --with for ephemeral dependencies
  - uv sync for project syncing
  - Malware blocking verification for all methods
- Update documentation and package.json
- Install uv in Docker test environment
This commit is contained in:
Reinier Criel 2025-11-25 14:10:20 -08:00
parent 5b6fe659c2
commit cab3a0aba3
14 changed files with 739 additions and 9 deletions

View file

@ -10,6 +10,9 @@ import {
} from "./pnpm/createPackageManager.js";
import { createYarnPackageManager } from "./yarn/createPackageManager.js";
import { createPipPackageManager } from "./pip/createPackageManager.js";
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { PIP_PACKAGE_MANAGER } from "./pip/pipSettings.js";
import { UV_PACKAGE_MANAGER } from "./uv/uvSettings.js";
/**
* @type {{packageManagerName: PackageManager | null}}
@ -52,8 +55,10 @@ export function initializePackageManager(packageManagerName) {
state.packageManagerName = createBunPackageManager();
} else if (packageManagerName === "bunx") {
state.packageManagerName = createBunxPackageManager();
} else if (packageManagerName === "pip") {
} else if (packageManagerName === PIP_PACKAGE_MANAGER) {
state.packageManagerName = createPipPackageManager();
} else if (packageManagerName === UV_PACKAGE_MANAGER) {
state.packageManagerName = createUvPackageManager();
} else {
throw new Error("Unsupported package manager: " + packageManagerName);
}

View file

@ -0,0 +1,19 @@
import { runUv } from "./runUvCommand.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createUvPackageManager() {
return {
/**
* @param {string[]} args
*/
runCommand: (args) => {
// uv is always invoked as 'uv' - no invocation variations like pip
return runUv("uv", args);
},
// For uv, rely solely on MITM proxy to detect/deny downloads from PyPI.
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}

View file

@ -0,0 +1,30 @@
import { test } from "node:test";
import assert from "node:assert";
import { createUvPackageManager } from "./createUvPackageManager.js";
test("createUvPackageManager", async (t) => {
await t.test("should create package manager with required interface", () => {
const pm = createUvPackageManager();
assert.ok(pm);
assert.strictEqual(typeof pm.runCommand, "function");
assert.strictEqual(typeof pm.isSupportedCommand, "function");
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
});
await t.test("should use proxy-only approach (MITM)", () => {
const pm = createUvPackageManager();
// uv uses proxy-only approach, so it doesn't scan args
assert.strictEqual(pm.isSupportedCommand(["pip", "install", "requests"]), false);
assert.strictEqual(pm.isSupportedCommand(["add", "requests"]), false);
assert.strictEqual(pm.isSupportedCommand([]), false);
});
await t.test("should return empty dependency updates", () => {
const pm = createUvPackageManager();
const result = pm.getDependencyUpdatesForCommand(["pip", "install", "requests"]);
assert.deepStrictEqual(result, []);
});
});

View file

@ -0,0 +1,76 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
/**
* Sets CA bundle environment variables used by Python libraries and uv.
* These are applied to ensure all Python network libraries respect the combined CA bundle.
*
* @param {NodeJS.ProcessEnv} env - Environment object to modify
* @param {string} combinedCaPath - Path to the combined CA bundle
*/
function setUvCaBundleEnvironmentVariables(env, combinedCaPath) {
// UV_NATIVE_TLS: Use system-provided TLS certificates (default is true)
// But we also need to provide our CA bundle for MITM'd connections
// SSL_CERT_FILE: Used by Python SSL libraries and underlying HTTP clients
if (env.SSL_CERT_FILE) {
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
}
env.SSL_CERT_FILE = combinedCaPath;
// REQUESTS_CA_BUNDLE: Used by the requests library (which uv may use internally)
if (env.REQUESTS_CA_BUNDLE) {
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
}
env.REQUESTS_CA_BUNDLE = combinedCaPath;
// PIP_CERT: Some underlying pip operations may respect this
if (env.PIP_CERT) {
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
}
env.PIP_CERT = combinedCaPath;
}
/**
* Runs a uv command with safe-chain's certificate bundle and proxy configuration.
*
* uv respects standard environment variables for proxy and TLS configuration:
* - HTTP_PROXY / HTTPS_PROXY: Proxy settings
* - SSL_CERT_FILE / REQUESTS_CA_BUNDLE: CA bundle for TLS verification
*
* @param {string} command - The uv command to execute (typically 'uv')
* @param {string[]} args - Command line arguments to pass to uv
* @returns {Promise<{status: number}>} Exit status of the uv command
*/
export async function runUv(command, args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
// Provide uv with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots)
// so that network requests validate correctly under both MITM'd and tunneled HTTPS.
const combinedCaPath = getCombinedCaBundlePath();
// Set CA bundle environment variables for uv and underlying Python libraries
setUvCaBundleEnvironmentVariables(env, combinedCaPath);
// Note: uv uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration
// These are already set by mergeSafeChainProxyEnvironmentVariables
const result = await safeSpawn(command, args, {
stdio: "inherit",
env,
});
return { status: result.status };
} catch (/** @type any */ error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError(`Error executing command: ${error.message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: 1 };
}
}
}

View file

@ -0,0 +1,5 @@
export const UV_PACKAGE_MANAGER = "uv";
// Unlike pip, uv only has one invocation method: the 'uv' command.
// There is no 'uv3' or 'python -m uv' pattern, so we don't need
// invocation tracking like pip does.