From 85273e29333e899f4e642652421f621e3b60eec3 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 9 Feb 2026 20:57:52 +0000 Subject: [PATCH] feat(toolchain): add automatic bundled Rust toolchain fallback and integrate with CLI/CargoRunner --- changelog.md | 11 ++ package.json | 4 +- pnpm-lock.yaml | 52 ++++---- readme.md | 16 ++- ts/00_commitinfo_data.ts | 2 +- ts/index.ts | 1 + ts/mod_cargo/classes.cargorunner.ts | 16 +-- ts/mod_cli/classes.tsrustcli.ts | 39 ++++-- ts/mod_toolchain/classes.toolchainmanager.ts | 119 +++++++++++++++++++ ts/mod_toolchain/index.ts | 1 + 10 files changed, 218 insertions(+), 43 deletions(-) create mode 100644 ts/mod_toolchain/classes.toolchainmanager.ts create mode 100644 ts/mod_toolchain/index.ts diff --git a/changelog.md b/changelog.md index da21f00..2299623 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2026-02-09 - 1.3.0 - feat(toolchain) +add automatic bundled Rust toolchain fallback and integrate with CLI/CargoRunner + +- Introduce ToolchainManager to download and install a minimal Rust toolchain to /tmp/tsrust_toolchain/ when system cargo is absent +- Add getEnvPrefix() and installation/verification logic to ToolchainManager; supports Linux and macOS (x64, arm64) +- Make CargoRunner accept an envPrefix and prepend it to cargo/rustup commands so bundled toolchain can be used transparently +- Update CLI to resolve toolchain at runtime (use system cargo if available; otherwise auto-install bundled toolchain) and pass envPrefix to CargoRunner for builds and clean +- Update exports to include mod_toolchain and add new ts/mod_toolchain module files +- Document the automatic toolchain behavior in readme.md and update usage description +- Bump dependencies: @push.rocks/smartcli ^4.0.20 and @types/node ^25.2.2 + ## 2026-02-09 - 1.2.0 - feat(cli) support default cross-compilation targets from npmextra.json diff --git a/package.json b/package.json index aad0c23..a494a93 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dependencies": { "@push.rocks/early": "^4.0.4", "@push.rocks/npmextra": "^5.3.3", - "@push.rocks/smartcli": "^4.0.19", + "@push.rocks/smartcli": "^4.0.20", "@push.rocks/smartfile": "^13.1.2", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartshell": "^3.0.6", @@ -45,7 +45,7 @@ "@git.zone/tsbuild": "^4.1.2", "@git.zone/tsrun": "^2.0.1", "@git.zone/tstest": "^3.1.4", - "@types/node": "^22.15.0" + "@types/node": "^25.2.2" }, "files": [ "ts/**/*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7005f15..6ce5c96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ importers: specifier: ^3.1.4 version: 3.1.8(socks@2.8.7)(typescript@5.9.3) '@types/node': - specifier: ^22.15.0 - version: 22.19.10 + specifier: ^25.2.2 + version: 25.2.2 packages: @@ -1398,6 +1398,9 @@ packages: '@types/node@22.19.10': resolution: {integrity: sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==} + '@types/node@25.2.2': + resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} + '@types/ping@0.4.4': resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==} @@ -3275,6 +3278,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -5846,27 +5852,27 @@ snapshots: '@types/bn.js@5.2.0': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/buffer-json@2.0.3': {} '@types/clean-css@4.2.11': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 source-map: 0.6.1 '@types/connect@3.4.38': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/cors@2.8.19': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/debug@4.1.12': dependencies: @@ -5874,7 +5880,7 @@ snapshots: '@types/dns-packet@5.6.5': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/elliptic@6.4.18': dependencies: @@ -5882,7 +5888,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -5896,7 +5902,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/hast@3.0.4': dependencies: @@ -5918,7 +5924,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/mdast@4.0.4': dependencies: @@ -5932,16 +5938,20 @@ snapshots: '@types/mute-stream@0.0.4': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/node-forge@1.3.14': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/node@22.19.10': dependencies: undici-types: 6.21.0 + '@types/node@25.2.2': + dependencies: + undici-types: 7.16.0 + '@types/ping@0.4.4': {} '@types/qs@6.14.0': {} @@ -5956,22 +5966,22 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/symbol-tree@3.2.5': {} '@types/tar-stream@3.1.4': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/through2@2.0.41': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/trusted-types@2.0.7': {} @@ -5997,11 +6007,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 '@types/yauzl@2.10.3': dependencies: - '@types/node': 22.19.10 + '@types/node': 25.2.2 optional: true '@ungap/structured-clone@1.3.0': {} @@ -6406,7 +6416,7 @@ snapshots: engine.io@6.6.4: dependencies: '@types/cors': 2.8.19 - '@types/node': 22.19.10 + '@types/node': 25.2.2 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -8192,6 +8202,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 diff --git a/readme.md b/readme.md index f1a53ab..590c5de 100644 --- a/readme.md +++ b/readme.md @@ -20,7 +20,7 @@ Or as a project-level dev dependency: pnpm install --save-dev @git.zone/tsrust ``` -> ⚡ **Prerequisite:** You need a working Rust toolchain. Install via [rustup.rs](https://rustup.rs/) if you haven't already. +> ⚡ **No Rust required!** If `cargo` isn't found on your system, `tsrust` automatically downloads and installs a minimal Rust toolchain to `/tmp/tsrust_toolchain/`. This gives a zero-setup experience. If you already have Rust installed, `tsrust` uses your system toolchain. ## The Convention @@ -44,7 +44,7 @@ tsrust ``` This will: -1. Verify that `cargo` is available +1. Detect the Rust toolchain (system `cargo`, or auto-install to `/tmp/tsrust_toolchain/`) 2. Locate your `rust/` directory (containing `Cargo.toml`) 3. Parse the workspace to discover all `[[bin]]` targets 4. Run `cargo build --release` with full streaming output @@ -65,6 +65,18 @@ Copied rustproxy (13.4 MB) -> dist_rust/rustproxy Done in 29.2s ``` +### Automatic Rust Toolchain + +`tsrust` provides a zero-setup experience through automatic toolchain management: + +1. **System toolchain detected** → uses it as-is (no download, no overhead) +2. **No system toolchain** → checks `/tmp/tsrust_toolchain/` for a previously installed bundled toolchain +3. **No bundled toolchain** → downloads `rustup-init` and installs a minimal Rust toolchain (~70-90 MB download) to `/tmp/tsrust_toolchain/` + +The bundled toolchain is stored in `/tmp/`, so it's cleaned up on reboot. Subsequent runs reuse the existing installation. This is ideal for CI environments and quick starts where you don't want to manage a system-wide Rust install. + +Supported platforms for automatic install: Linux (x64, arm64) and macOS (x64, arm64). + ### 🐛 Debug Build Build with the debug profile instead of release: diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 97b1350..1a7c193 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tsrust', - version: '1.2.0', + version: '1.3.0', description: 'A tool for compiling Rust projects, detecting Cargo workspaces, building with cargo, and placing binaries in a conventional dist_rust directory.' } diff --git a/ts/index.ts b/ts/index.ts index 672a568..405d931 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -4,5 +4,6 @@ plugins.early.start('@git.zone/tsrust'); export * from './mod_fs/index.js'; export * from './mod_cargo/index.js'; export * from './mod_cli/index.js'; +export * from './mod_toolchain/index.js'; plugins.early.stop(); diff --git a/ts/mod_cargo/classes.cargorunner.ts b/ts/mod_cargo/classes.cargorunner.ts index 17f94bb..93e32e1 100644 --- a/ts/mod_cargo/classes.cargorunner.ts +++ b/ts/mod_cargo/classes.cargorunner.ts @@ -9,21 +9,23 @@ export interface ICargoRunResult { export class CargoRunner { private shell: plugins.smartshell.Smartshell; private rustDir: string; + private envPrefix: string; - constructor(rustDir: string) { + constructor(rustDir: string, envPrefix: string = '') { this.rustDir = rustDir; + this.envPrefix = envPrefix; this.shell = new plugins.smartshell.Smartshell({ executor: 'bash', }); } public async checkCargoInstalled(): Promise { - const result = await this.shell.execSilent('cargo --version'); + const result = await this.shell.execSilent(`${this.envPrefix}cargo --version`); return result.exitCode === 0; } public async getCargoVersion(): Promise { - const result = await this.shell.execSilent('cargo --version'); + const result = await this.shell.execSilent(`${this.envPrefix}cargo --version`); return result.stdout.trim(); } @@ -39,7 +41,7 @@ export class CargoRunner { const profile = options.debug ? '' : ' --release'; const targetFlag = options.target ? ` --target ${options.target}` : ''; - const command = `cd ${this.rustDir} && cargo build${profile}${targetFlag}`; + const command = `${this.envPrefix}cd ${this.rustDir} && cargo build${profile}${targetFlag}`; console.log(`Running: cargo build${profile}${targetFlag}`); const result = await this.shell.exec(command); @@ -55,20 +57,20 @@ export class CargoRunner { * Ensures a rustup target is installed. If not present, installs it via `rustup target add`. */ public async ensureTarget(triple: string): Promise { - const listResult = await this.shell.execSilent('rustup target list --installed'); + const listResult = await this.shell.execSilent(`${this.envPrefix}rustup target list --installed`); const installedTargets = listResult.stdout.split('\n').map((l) => l.trim()); if (installedTargets.includes(triple)) { return; } console.log(`Installing rustup target: ${triple}`); - const addResult = await this.shell.exec(`rustup target add ${triple}`); + const addResult = await this.shell.exec(`${this.envPrefix}rustup target add ${triple}`); if (addResult.exitCode !== 0) { throw new Error(`Failed to install rustup target ${triple}`); } } public async clean(): Promise { - const command = `cd ${this.rustDir} && cargo clean`; + const command = `${this.envPrefix}cd ${this.rustDir} && cargo clean`; const result = await this.shell.exec(command); return { diff --git a/ts/mod_cli/classes.tsrustcli.ts b/ts/mod_cli/classes.tsrustcli.ts index 9c3c8f2..40db382 100644 --- a/ts/mod_cli/classes.tsrustcli.ts +++ b/ts/mod_cli/classes.tsrustcli.ts @@ -3,6 +3,7 @@ import * as plugins from '../plugins.js'; import { CargoConfig } from '../mod_cargo/index.js'; import { CargoRunner } from '../mod_cargo/index.js'; import { FsHelpers } from '../mod_fs/index.js'; +import { ToolchainManager } from '../mod_toolchain/index.js'; /** Maps friendly target names to Rust target triples */ const targetAliasMap: Record = { @@ -62,18 +63,33 @@ export class TsRustCli { this.registerCleanCommand(); } + /** + * Resolves the Rust toolchain to use. Tries system cargo first, + * then falls back to a bundled toolchain at /tmp/tsrust_toolchain/. + * Returns the envPrefix string to prepend to shell commands. + */ + private async resolveToolchain(): Promise { + // 1. Try system cargo + const systemRunner = new CargoRunner(this.cwd); + if (await systemRunner.checkCargoInstalled()) { + return ''; // system toolchain, no prefix needed + } + + // 2. Fall back to bundled toolchain + console.log('System cargo not found. Checking for bundled toolchain...'); + const toolchain = new ToolchainManager(); + await toolchain.ensureInstalled(); + return toolchain.getEnvPrefix(); + } + private registerStandardCommand(): void { this.cli.standardCommand().subscribe(async (argvArg) => { const startTime = Date.now(); - // Check cargo is installed - const runner = new CargoRunner(this.cwd); // temporary, just for version check - if (!(await runner.checkCargoInstalled())) { - console.error('Error: cargo is not installed or not in PATH.'); - console.error('Install Rust via https://rustup.rs/'); - process.exit(1); - } + // Resolve toolchain (system or bundled fallback) + const envPrefix = await this.resolveToolchain(); + const runner = new CargoRunner(this.cwd, envPrefix); const cargoVersion = await runner.getCargoVersion(); console.log(`Using ${cargoVersion}`); @@ -125,7 +141,7 @@ export class TsRustCli { for (const { triple, friendly } of resolvedTargets) { console.log(`\n--- Building for ${friendly} (${triple}) ---`); - const cargoRunner = new CargoRunner(rustDir); + const cargoRunner = new CargoRunner(rustDir, envPrefix); const buildResult = await cargoRunner.build({ debug: isDebug, clean: shouldClean, target: triple }); if (!buildResult.success) { @@ -159,8 +175,8 @@ export class TsRustCli { } } } else { - // Native build (unchanged behavior) - const cargoRunner = new CargoRunner(rustDir); + // Native build + const cargoRunner = new CargoRunner(rustDir, envPrefix); const buildResult = await cargoRunner.build({ debug: isDebug, clean: shouldClean }); if (!buildResult.success) { @@ -199,8 +215,9 @@ export class TsRustCli { // Clean cargo build const rustDir = await this.detectRustDir(); if (rustDir) { + const envPrefix = await this.resolveToolchain(); console.log('Running cargo clean...'); - const runner = new CargoRunner(rustDir); + const runner = new CargoRunner(rustDir, envPrefix); await runner.clean(); console.log('Cargo clean complete.'); } diff --git a/ts/mod_toolchain/classes.toolchainmanager.ts b/ts/mod_toolchain/classes.toolchainmanager.ts new file mode 100644 index 0000000..97f9785 --- /dev/null +++ b/ts/mod_toolchain/classes.toolchainmanager.ts @@ -0,0 +1,119 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as plugins from '../plugins.js'; + +export class ToolchainManager { + public static TOOLCHAIN_DIR = '/tmp/tsrust_toolchain'; + public static RUSTUP_HOME = '/tmp/tsrust_toolchain/rustup'; + public static CARGO_HOME = '/tmp/tsrust_toolchain/cargo'; + public static BIN_DIR = '/tmp/tsrust_toolchain/cargo/bin'; + + private shell: plugins.smartshell.Smartshell; + + constructor() { + this.shell = new plugins.smartshell.Smartshell({ + executor: 'bash', + }); + } + + /** + * Returns the Rust host triple for the current platform. + */ + public getHostTriple(): string { + const platform = os.platform(); + const arch = os.arch(); + + if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-gnu'; + if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-gnu'; + if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin'; + if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin'; + + throw new Error( + `Unsupported platform: ${platform}/${arch}. Please install Rust manually via https://rustup.rs/` + ); + } + + /** + * Checks if the bundled toolchain is already installed at TOOLCHAIN_DIR. + */ + public async isInstalled(): Promise { + const cargoPath = path.join(ToolchainManager.BIN_DIR, 'cargo'); + try { + const stat = await fs.promises.stat(cargoPath); + return stat.isFile(); + } catch { + return false; + } + } + + /** + * Downloads rustup-init and installs a minimal Rust toolchain to TOOLCHAIN_DIR. + */ + public async install(): Promise { + const triple = this.getHostTriple(); + const rustupInitUrl = `https://static.rust-lang.org/rustup/dist/${triple}/rustup-init`; + const rustupInitPath = path.join(ToolchainManager.TOOLCHAIN_DIR, 'rustup-init'); + + // Ensure toolchain directory exists + await fs.promises.mkdir(ToolchainManager.TOOLCHAIN_DIR, { recursive: true }); + + console.log(`Downloading rustup-init for ${triple}...`); + const downloadResult = await this.shell.exec( + `curl -sSf -o ${rustupInitPath} ${rustupInitUrl} && chmod +x ${rustupInitPath}` + ); + if (downloadResult.exitCode !== 0) { + throw new Error(`Failed to download rustup-init: ${downloadResult.stdout}`); + } + + console.log('Installing minimal Rust toolchain to /tmp/tsrust_toolchain/...'); + const installCmd = [ + `RUSTUP_HOME="${ToolchainManager.RUSTUP_HOME}"`, + `CARGO_HOME="${ToolchainManager.CARGO_HOME}"`, + rustupInitPath, + '-y', + '--default-toolchain stable', + '--profile minimal', + '--no-modify-path', + ].join(' '); + + const installResult = await this.shell.exec(installCmd); + if (installResult.exitCode !== 0) { + throw new Error(`Failed to install Rust toolchain: ${installResult.stdout}`); + } + + // Verify installation + const verifyResult = await this.shell.execSilent( + `${this.getEnvPrefix()}cargo --version` + ); + if (verifyResult.exitCode !== 0) { + throw new Error('Rust toolchain installation verification failed.'); + } + console.log(`Installed: ${verifyResult.stdout.trim()}`); + + // Clean up rustup-init binary + await fs.promises.unlink(rustupInitPath).catch(() => {}); + } + + /** + * Ensures the bundled toolchain is installed. Downloads if not present. + */ + public async ensureInstalled(): Promise { + if (await this.isInstalled()) { + console.log('Using bundled Rust toolchain from /tmp/tsrust_toolchain/'); + return; + } + await this.install(); + } + + /** + * Returns a shell prefix string that sets RUSTUP_HOME, CARGO_HOME, and PATH + * to point at the bundled toolchain. Prepend this to any shell command. + */ + public getEnvPrefix(): string { + return ( + `export RUSTUP_HOME="${ToolchainManager.RUSTUP_HOME}" CARGO_HOME="${ToolchainManager.CARGO_HOME}" && ` + + `export PATH="${ToolchainManager.BIN_DIR}:$PATH" && ` + ); + } +} diff --git a/ts/mod_toolchain/index.ts b/ts/mod_toolchain/index.ts new file mode 100644 index 0000000..ca913bb --- /dev/null +++ b/ts/mod_toolchain/index.ts @@ -0,0 +1 @@ +export * from './classes.toolchainmanager.js';