diff --git a/sdk_v2/js/README.md b/sdk_v2/js/README.md index 05528f708..f0a4e5d2a 100644 --- a/sdk_v2/js/README.md +++ b/sdk_v2/js/README.md @@ -156,6 +156,80 @@ The **C++ wrapper header** (`foundry_local_cpp.h`) stays C++17-consumable becaus C++ consumers need to include it from any toolchain. The addon happily includes a C++17 header from a C++20 TU. +### 3.4 Native runtime install (ORT / ORT-GenAI via NuGet) + +`npm install` runs [`script/install-native.cjs`](script/install-native.cjs) as an install +lifecycle step, which downloads the ONNX Runtime and ORT-GenAI native binaries from NuGet and stages +them into `prebuilds/-/`. Three modes are supported: + +- **`http` (default)** — talks to the NuGet v3 HTTP protocol directly (service index -> + `PackageBaseAddress` -> `.nupkg`) with Node's built-in `https` module. No external tools + required. Feeds are queried anonymously; use `dotnet` or `nuget` mode for feeds that + require authentication. +- **`dotnet`** — shells out to `dotnet restore` against a throwaway project. Use this for a + private feed whose auth is wired through the .NET credential-provider ecosystem (e.g. the + Azure Artifacts Credential Provider) or a `NuGet.config`. Cross-platform, needs only the + .NET SDK. +- **`nuget`** — shells out to `nuget.exe install` (or a `nuget` on PATH) once per artifact. + Useful when your feed's auth is supplied by a NuGet/Visual Studio credential provider + (`CredentialProvider.Microsoft`, etc.) that `dotnet restore` can't host — for example a + netfx-only provider plugin. `dotnet` remains the cross-platform option when both work. + +Set `FOUNDRY_LOCAL_SKIP_INSTALL=1` to skip the step entirely (e.g. when building from source +and copying binaries via `copy-native:dev` instead). + +| Variable | Applies to | Purpose | +|------------------------------------|--------------------|-------------------------------------------------------------------------------------------------------| +| `FOUNDRY_LOCAL_NUGET_MODE` | all | `http` (default), `dotnet`, or `nuget`. Any other value is rejected. | +| `FOUNDRY_LOCAL_NUGET_FEEDS` | all | `;`-separated NuGet v3 service index URLs. Replaces the public defaults entirely. `http` mode requires HTTPS; `dotnet`/`nuget` do not. | +| `FOUNDRY_LOCAL_NUGET_CONFIG` | `dotnet`, `nuget` | Path to a `NuGet.config`. When set, the config owns package sources (`--configfile`/`-ConfigFile`, no `--source`/`-Source`). Rejected in `http` mode. | +| `FOUNDRY_LOCAL_DOTNET_COMMAND` | `dotnet` only | Command or path to the `dotnet` executable. Defaults to `dotnet`. Rejected in `http`/`nuget` mode. | +| `FOUNDRY_LOCAL_NUGET_COMMAND` | `nuget` only | Command or path to the `nuget` executable. Defaults to `nuget.exe` on Windows, `nuget` elsewhere. Rejected in `http`/`dotnet` mode. | + +Authentication is delegated to the NuGet tooling in `dotnet`/`nuget` mode (a `NuGet.config` +or a credential provider), so no credentials pass through this script. Query strings and +fragments (which can carry SAS tokens) are stripped from every logged or thrown URL, and +`nuget`/`dotnet` mode never print their full command line — only stdout/stderr on failure, +with URLs redacted. + +**Example — custom anonymous feed, HTTP mode (PowerShell):** + +```pwsh +$env:FOUNDRY_LOCAL_NUGET_FEEDS = "https://pkgs.dev.azure.com/my-org/_packaging/my-feed/nuget/v3/index.json" +npm install +``` + +**Example — dotnet mode with a NuGet.config (either shell):** + +The `NuGet.config` owns the package sources (and any credentials), so no feed variable is set here. + +```pwsh +$env:FOUNDRY_LOCAL_NUGET_MODE = "dotnet" +$env:FOUNDRY_LOCAL_NUGET_CONFIG = "C:\secrets\NuGet.config" +npm install +``` + +```bash +export FOUNDRY_LOCAL_NUGET_MODE=dotnet +export FOUNDRY_LOCAL_NUGET_CONFIG=/etc/secrets/NuGet.config +npm install +``` + +**Example — nuget mode against a private Azure Artifacts feed, authenticated via +a NuGet/Visual Studio credential provider (PowerShell):** + +```pwsh +$env:FOUNDRY_LOCAL_NUGET_MODE = "nuget" +$env:FOUNDRY_LOCAL_NUGET_COMMAND = "C:\tools\nuget\nuget.exe" +$env:FOUNDRY_LOCAL_NUGET_FEEDS = "https://pkgs.dev.azure.com/my-org/_packaging/my-feed/nuget/v3/index.json" +npm install +``` + +This mode is useful precisely when the feed's anonymous access is disabled and auth is +supplied out-of-band by a NuGet/Visual Studio credential provider (`CredentialProvider.Microsoft`) +that `dotnet restore` cannot host — `nuget.exe` on Windows can invoke netfx credential provider +plugins. `dotnet` remains the cross-platform option when your feed's credential provider supports it. + --- ## 4. Using the C++ SDK directly (without the JS layer) diff --git a/sdk_v2/js/docs/PortJsToSdkV2.md b/sdk_v2/js/docs/PortJsToSdkV2.md index 081eca81f..439def37b 100644 --- a/sdk_v2/js/docs/PortJsToSdkV2.md +++ b/sdk_v2/js/docs/PortJsToSdkV2.md @@ -176,13 +176,27 @@ on the v2 layer: and the addon for every (platform × arch), drops each `(.node addon + foundry_local.{dll,so,dylib})` pair into `prebuilds/-/`, then `npm pack`s a single tarball - containing all variants. At install time, `npm install` just unpacks the - tarball — there is no postinstall download step, no separate artifact - host, and no network access beyond the normal npm fetch. At runtime, the - loader picks the matching `prebuilds/-/` - subdirectory. If a consumer is on an unsupported platform, the addon - load fails with a clear error; there is no automatic source-build - fallback in the published package. + containing all variants. `npm install` unpacks the tarball — the addon + and `foundry_local.{dll,so,dylib}` themselves require no postinstall + download. At runtime, the loader picks the matching + `prebuilds/-/` subdirectory. If a + consumer is on an unsupported platform, the addon load fails with a + clear error; there is no automatic source-build fallback in the + published package. +- **ORT / ORT-GenAI *are* fetched during the install lifecycle.** Unlike + `foundry_local` itself, the ONNX Runtime and ORT-GenAI native binaries + are not bundled in the tarball — `script/install-native.cjs` runs as + the package's `install` script and downloads them from NuGet into the + same `prebuilds/-/` directory. It supports an `http` + mode (default, raw NuGet v3 protocol), a `dotnet` mode (`dotnet + restore` against a throwaway project, for feeds that need .NET + credential-provider auth), and a `nuget` mode (`nuget.exe install` per + artifact, for feeds whose auth is supplied by a NuGet/Visual Studio + credential provider that `dotnet restore` can't host). Feeds and the + `NuGet.config` path are configurable via + `FOUNDRY_LOCAL_NUGET_*` env vars; see + [README.md § 3.4](../README.md#34-native-runtime-install-ort--ort-genai-via-nuget). + Set `FOUNDRY_LOCAL_SKIP_INSTALL=1` to opt out entirely. - **Dev / source builds load the native from the canonical C++ build dir.** Per [cpp-build.instructions.md](../../../.github/instructions/cpp-build.instructions.md), diff --git a/sdk_v2/js/script/install-native.cjs b/sdk_v2/js/script/install-native.cjs index f543ddce6..ae337819c 100644 --- a/sdk_v2/js/script/install-native.cjs +++ b/sdk_v2/js/script/install-native.cjs @@ -5,6 +5,9 @@ // NuGet and stages them into sdk_v2/js/prebuilds/-/ next to // foundry_local.{dll,so,dylib} and the .node addons. // +// Uses the NuGet v3 HTTP API by default, or an explicit dotnet-restore or +// nuget.exe-install mode for feeds configured through NuGet credential providers. +// // Ported from sdk/js/script/install-{standard,utils}.cjs. Differences: // * Targets sdk_v2/js/prebuilds/-/ (v2's single addon dir) // instead of v1's per-platform foundry-local-core/ subpackage layout. @@ -13,218 +16,525 @@ // * Reads sdk_v2/deps_versions.json with the same dual-path fallback v1 // uses (next to the script when published, two levels up in the repo). -'use strict'; - -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const https = require('node:https'); -const AdmZip = require('adm-zip'); +"use strict"; -if (process.env.FOUNDRY_LOCAL_SKIP_INSTALL === '1') { - console.log('[foundry-local] FOUNDRY_LOCAL_SKIP_INSTALL=1 set; skipping native runtime download.'); - process.exit(0); -} +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const https = require("node:https"); +const { spawnSync } = require("node:child_process"); +const AdmZip = require("adm-zip"); const PLATFORM_MAP = { - 'win32-x64': 'win-x64', - 'win32-arm64': 'win-arm64', - 'linux-x64': 'linux-x64', - 'linux-arm64': 'linux-arm64', - 'darwin-arm64': 'osx-arm64', + "win32-x64": "win-x64", + "win32-arm64": "win-arm64", + "linux-x64": "linux-x64", + "linux-arm64": "linux-arm64", + "darwin-arm64": "osx-arm64", }; -const platformKey = `${os.platform()}-${os.arch()}`; -const RID = PLATFORM_MAP[platformKey]; -if (!RID) { - console.warn(`[foundry-local] Unsupported platform: ${platformKey}. Skipping native runtime install.`); - process.exit(0); -} +const DEFAULT_FEEDS = [ + "https://api.nuget.org/v3/index.json", + "https://pkgs.dev.azure.com/aiinfra/PublicPackages/_packaging/ORT-Nightly/nuget/v3/index.json", +]; -const EXT = os.platform() === 'win32' ? '.dll' : os.platform() === 'darwin' ? '.dylib' : '.so'; -const LIB_PREFIX = os.platform() === 'win32' ? '' : 'lib'; +const VALID_MODES = new Set(["http", "dotnet", "nuget"]); + +function readConfig(env) { + const modeRaw = env.FOUNDRY_LOCAL_NUGET_MODE; + const mode = modeRaw === undefined || modeRaw === "" ? "http" : modeRaw; + if (!VALID_MODES.has(mode)) { + throw new Error(`Invalid FOUNDRY_LOCAL_NUGET_MODE '${modeRaw}'. Expected 'http', 'dotnet', or 'nuget'.`); + } + + const feedsRaw = env.FOUNDRY_LOCAL_NUGET_FEEDS; + const feeds = + feedsRaw === undefined + ? DEFAULT_FEEDS.slice() + : feedsRaw + .split(";") + .map((f) => f.trim()) + .filter(Boolean); + if (feedsRaw !== undefined && feeds.length === 0) { + throw new Error("FOUNDRY_LOCAL_NUGET_FEEDS is set but contains no feed URLs."); + } + for (const feed of feeds) { + try { + const url = new URL(feed); + if (url.username || url.password) { + throw new Error("embedded credentials are not supported"); + } + } catch { + throw new Error(`FOUNDRY_LOCAL_NUGET_FEEDS contains an invalid URL: ${redactUrl(feed)}`); + } + } -const BIN_DIR = path.join(__dirname, '..', 'prebuilds', platformKey); + const configFile = env.FOUNDRY_LOCAL_NUGET_CONFIG || undefined; + const dotnetCommandRaw = env.FOUNDRY_LOCAL_DOTNET_COMMAND || undefined; + const nugetCommandRaw = env.FOUNDRY_LOCAL_NUGET_COMMAND || undefined; -const depsPath = fs.existsSync(path.resolve(__dirname, '..', 'deps_versions.json')) - ? path.resolve(__dirname, '..', 'deps_versions.json') - : path.resolve(__dirname, '..', '..', 'deps_versions.json'); + if (mode === "http") { + for (const feed of feeds) { + if (new URL(feed).protocol !== "https:") { + throw new Error(`FOUNDRY_LOCAL_NUGET_FEEDS must use HTTPS in http mode: ${redactUrl(feed)}`); + } + } + if (configFile) { + throw new Error("FOUNDRY_LOCAL_NUGET_CONFIG is only valid when FOUNDRY_LOCAL_NUGET_MODE is 'dotnet' or 'nuget'."); + } + if (dotnetCommandRaw) { + throw new Error("FOUNDRY_LOCAL_DOTNET_COMMAND is only valid when FOUNDRY_LOCAL_NUGET_MODE=dotnet."); + } + if (nugetCommandRaw) { + throw new Error("FOUNDRY_LOCAL_NUGET_COMMAND is only valid when FOUNDRY_LOCAL_NUGET_MODE=nuget."); + } + } else { + if (mode === "dotnet" && nugetCommandRaw) { + throw new Error("FOUNDRY_LOCAL_NUGET_COMMAND is only valid when FOUNDRY_LOCAL_NUGET_MODE=nuget."); + } + if (mode === "nuget" && dotnetCommandRaw) { + throw new Error("FOUNDRY_LOCAL_DOTNET_COMMAND is only valid when FOUNDRY_LOCAL_NUGET_MODE=dotnet."); + } + } + + // nuget.exe is the canonical distribution name on Windows; POSIX users typically shim a + // `nuget` script (e.g. `mono nuget.exe`) onto PATH. FOUNDRY_LOCAL_NUGET_COMMAND overrides either way. + const defaultNugetCommand = os.platform() === "win32" ? "nuget.exe" : "nuget"; + + return { + mode, + feeds, + configFile, + dotnetCommand: dotnetCommandRaw || "dotnet", + nugetCommand: nugetCommandRaw || defaultNugetCommand, + }; +} -if (!fs.existsSync(depsPath)) { - console.error(`[foundry-local] deps_versions.json not found at ${depsPath}`); - process.exit(1); +function redactUrl(urlString) { + try { + const u = new URL(urlString); + u.username = ""; + u.password = ""; + u.search = ""; + u.hash = ""; + return u.toString(); + } catch { + return urlString; + } } -const deps = JSON.parse(fs.readFileSync(depsPath, 'utf8')); - -const ortVersion = deps.onnxruntime.version; -const genaiVersion = deps['onnxruntime-genai'].version; - -// ORT's dylib/so soname uses the major version only (e.g. libonnxruntime.1.dylib, -// libonnxruntime.so.1), which is the name libfoundry_local records as its dependency. -const ortMajor = ortVersion.split('.')[0]; - -// Expected post-install filenames per platform. On Linux/macOS we rename or -// symlink the unversioned ORT lib to a versioned name to match what -// libfoundry_local.{so,dylib} actually requests at load time. -function expectedOrt() { - if (os.platform() === 'linux') return 'libonnxruntime.so.1'; - if (os.platform() === 'darwin') return `libonnxruntime.${ortMajor}.dylib`; - return 'onnxruntime.dll'; + +function redactUrlsInText(text) { + return String(text).replace(/https?:\/\/[^\s"'<>]+/gi, (url) => redactUrl(url)); } -function expectedGenai() { - return `${LIB_PREFIX}onnxruntime-genai${EXT}`; + +function safeErrorMessage(error) { + return redactUrlsInText(error instanceof Error ? error.message : String(error)); } -const ARTIFACTS = [ - { name: 'Microsoft.ML.OnnxRuntime', version: ortVersion, expected: expectedOrt() }, - { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: genaiVersion, expected: expectedGenai() }, -]; +function detectPlatform() { + const platformKey = `${os.platform()}-${os.arch()}`; + const rid = PLATFORM_MAP[platformKey]; + if (!rid) return null; + const ext = os.platform() === "win32" ? ".dll" : os.platform() === "darwin" ? ".dylib" : ".so"; + const libPrefix = os.platform() === "win32" ? "" : "lib"; + return { platformKey, rid, ext, libPrefix }; +} -const FEEDS = [ - 'https://api.nuget.org/v3/index.json', - 'https://pkgs.dev.azure.com/aiinfra/PublicPackages/_packaging/ORT-Nightly/nuget/v3/index.json', -]; +function loadDeps(pkgRoot) { + const depsPath = fs.existsSync(path.join(pkgRoot, "deps_versions.json")) + ? path.join(pkgRoot, "deps_versions.json") + : path.resolve(pkgRoot, "..", "deps_versions.json"); + if (!fs.existsSync(depsPath)) { + throw new Error(`deps_versions.json not found at ${depsPath}`); + } + return JSON.parse(fs.readFileSync(depsPath, "utf8")); +} -async function downloadWithRetryAndRedirects(url, destStream = null) { - const maxRedirects = 5; - let currentUrl = url; - let redirects = 0; - - while (redirects < maxRedirects) { - const response = await new Promise((resolve, reject) => { - https.get(currentUrl, (res) => resolve(res)).on('error', reject); - }); - - if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { - currentUrl = response.headers.location; - response.resume(); - redirects++; - console.log(` Following redirect to ${new URL(currentUrl).host}...`); - continue; - } +function buildArtifacts(deps, platform) { + const ortVersion = deps.onnxruntime.version; + const genaiVersion = deps["onnxruntime-genai"].version; + const ortMajor = ortVersion.split(".")[0]; + + // libfoundry_local's SONAME/install_name dependency on ORT is versioned (see normalizeOrtLibName below); + // Windows has no soname concept so onnxruntime.dll stays unversioned. + const expectedOrt = () => { + if (platform.rid.startsWith("linux")) return "libonnxruntime.so.1"; + if (platform.rid.startsWith("osx")) return `libonnxruntime.${ortMajor}.dylib`; + return "onnxruntime.dll"; + }; + const expectedGenai = () => `${platform.libPrefix}onnxruntime-genai${platform.ext}`; + + return [ + { name: "Microsoft.ML.OnnxRuntime", version: ortVersion, expected: expectedOrt() }, + { name: "Microsoft.ML.OnnxRuntimeGenAI.Foundry", version: genaiVersion, expected: expectedGenai() }, + ]; +} - if (response.statusCode !== 200) { - throw new Error(`Download failed with status ${response.statusCode}: ${currentUrl}`); - } +async function downloadWithRetryAndRedirects(url, { destStream = null, request = https.get } = {}) { + const maxRedirects = 5; + let currentUrl = url; + let redirects = 0; - if (destStream) { - response.pipe(destStream); - return new Promise((resolve, reject) => { - destStream.on('finish', resolve); - destStream.on('error', reject); - response.on('error', reject); - }); - } + while (redirects < maxRedirects) { + const response = await new Promise((resolve, reject) => { + request(currentUrl, {}, (res) => resolve(res)).on("error", reject); + }); + + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + const nextUrl = new URL(response.headers.location, currentUrl).toString(); + response.resume(); + redirects++; + console.log(` Following redirect to ${new URL(nextUrl).host}...`); + currentUrl = nextUrl; + continue; + } + + if (response.statusCode !== 200) { + throw new Error(`Download failed with status ${response.statusCode}: ${redactUrl(currentUrl)}`); + } - let data = ''; - response.on('data', (chunk) => (data += chunk)); - return new Promise((resolve, reject) => { - response.on('end', () => resolve(data)); - response.on('error', reject); - }); + if (destStream) { + response.pipe(destStream); + return new Promise((resolve, reject) => { + destStream.on("finish", resolve); + destStream.on("error", reject); + response.on("error", reject); + }); } - throw new Error('Too many redirects'); + + let data = ""; + response.on("data", (chunk) => { + data += chunk; + }); + return new Promise((resolve, reject) => { + response.on("end", () => resolve(data)); + response.on("error", reject); + }); + } + throw new Error(`Too many redirects: ${redactUrl(url)}`); +} + +async function downloadJson(url, opts) { + return JSON.parse(await downloadWithRetryAndRedirects(url, opts)); +} + +async function downloadFile(url, dest, opts) { + const file = fs.createWriteStream(dest); + try { + await downloadWithRetryAndRedirects(url, { ...opts, destStream: file }); + file.close(); + } catch (e) { + file.close(); + if (fs.existsSync(dest)) fs.unlinkSync(dest); + throw e; + } +} + +async function getBaseAddress(feedUrl, cache) { + if (!cache.has(feedUrl)) { + cache.set(feedUrl, await downloadJson(feedUrl)); + } + const resources = cache.get(feedUrl).resources || []; + const res = resources.find((r) => r["@type"]?.startsWith("PackageBaseAddress/3.0.0")); + if (!res) throw new Error(`Could not find PackageBaseAddress/3.0.0 in NuGet feed: ${redactUrl(feedUrl)}`); + const baseAddress = res["@id"]; + const normalized = baseAddress.endsWith("/") ? baseAddress : `${baseAddress}/`; + if (new URL(normalized).protocol !== "https:") { + throw new Error(`PackageBaseAddress must use HTTPS in http mode: ${redactUrl(normalized)}`); + } + return normalized; +} + +function entryFileName(entry) { + const normalized = entry.entryName.replace(/\\/g, "/"); + return normalized.slice(normalized.lastIndexOf("/") + 1); } -async function downloadJson(url) { - return JSON.parse(await downloadWithRetryAndRedirects(url)); +function isNativeFileName(name, ext) { + return name.toLowerCase().endsWith(ext) || /\.so(\.\d+)+$/i.test(name); } -async function downloadFile(url, dest) { - const file = fs.createWriteStream(dest); +function nativeEntriesForRid(zip, rid, ext) { + const nativePrefix = `runtimes/${rid}/native/`.toLowerCase(); + const runtimePrefix = `runtimes/${rid}/`.toLowerCase(); + return zip.getEntries().filter((e) => { + const p = e.entryName.toLowerCase(); + if (!isNativeFileName(p, ext)) return false; + if (p.startsWith(nativePrefix)) return true; + if (p.startsWith(runtimePrefix)) { + const relative = p.slice(runtimePrefix.length); + return relative.length > 0 && !relative.includes("/"); + } + return false; + }); +} + +async function installPackageHttp(artifact, tempDir, binDir, config, platform, cache) { + if (artifact.expected && fs.existsSync(path.join(binDir, artifact.expected))) { + console.log(` ${artifact.name}: ${artifact.expected} already present, skipping download.`); + return; + } + + let lastError; + const { feeds } = config; + for (let i = 0; i < feeds.length; i++) { + const feedUrl = feeds[i]; + const feedHost = new URL(feedUrl).host; try { - await downloadWithRetryAndRedirects(url, file); - file.close(); - } catch (e) { - file.close(); - if (fs.existsSync(dest)) fs.unlinkSync(dest); - throw e; + const baseAddress = await getBaseAddress(feedUrl, cache); + const nameLower = artifact.name.toLowerCase(); + const verLower = artifact.version.toLowerCase(); + const downloadUrl = `${baseAddress}${nameLower}/${verLower}/${nameLower}.${verLower}.nupkg`; + + const nupkgPath = path.join(tempDir, `${artifact.name}.${artifact.version}.nupkg`); + console.log(` Downloading ${artifact.name} ${artifact.version} from ${feedHost}...`); + await downloadFile(downloadUrl, nupkgPath); + + console.log(" Extracting..."); + const zip = new AdmZip(nupkgPath); + const entries = nativeEntriesForRid(zip, platform.rid, platform.ext); + if (entries.length === 0) { + throw new Error( + [ + `No native files found for RID '${platform.rid}' in ${artifact.name} ${artifact.version}.`, + "The package may not yet support this platform.", + "Set FOUNDRY_LOCAL_SKIP_INSTALL=1 to bypass if you are building from source.", + ].join(" "), + ); + } + for (const entry of entries) { + zip.extractEntryTo(entry, binDir, false, true); + console.log(` Extracted ${entryFileName(entry)}`); + } + return; + } catch (err) { + lastError = err; + const reason = safeErrorMessage(err); + if (i < feeds.length - 1) { + console.warn( + ` ${artifact.name} ${artifact.version}: download from ${feedHost} failed (${reason}); trying next feed...`, + ); + } + } + } + const feedHosts = feeds.map((f) => new URL(f).host).join(", "); + const reason = safeErrorMessage(lastError); + throw new Error( + `Failed to download ${artifact.name} ${artifact.version} from any configured feed (${feedHosts}): ${reason}`, + ); +} + +async function runHttpMode(config, artifacts, binDir, platform) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "foundry-install-")); + const serviceIndexCache = new Map(); + try { + for (const artifact of artifacts) { + await installPackageHttp(artifact, tempDir, binDir, config, platform, serviceIndexCache); } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } } -const serviceIndexCache = new Map(); +function escapeXml(value) { + return String(value).replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} + +function generateRestoreProjectXml(artifacts) { + const refs = artifacts + .map((a) => ` `) + .join("\n"); + return ` + + net8.0 + false + false + + +${refs} + + +`; +} -async function getBaseAddress(feedUrl) { - if (!serviceIndexCache.has(feedUrl)) { - serviceIndexCache.set(feedUrl, await downloadJson(feedUrl)); +function buildDotnetRestoreArgs(config, { projectPath, packagesDir }) { + const args = ["restore", projectPath, "--packages", packagesDir, "--no-cache"]; + if (config.configFile) { + args.push("--configfile", config.configFile); + } else { + for (const feed of config.feeds) { + args.push("--source", feed); } - const resources = serviceIndexCache.get(feedUrl).resources || []; - const res = resources.find((r) => r['@type'] && r['@type'].startsWith('PackageBaseAddress/3.0.0')); - if (!res) throw new Error('Could not find PackageBaseAddress/3.0.0 in NuGet feed.'); - const baseAddress = res['@id']; - return baseAddress.endsWith('/') ? baseAddress : `${baseAddress}/`; + } + return args; } -function entryFileName(entry) { - const normalized = entry.entryName.replace(/\\/g, '/'); - return normalized.slice(normalized.lastIndexOf('/') + 1); +function findRestoredPackageDir(packagesDir, id, version) { + const dir = path.join(packagesDir, id.toLowerCase(), version.toLowerCase()); + if (!fs.existsSync(dir)) { + throw new Error(`Restored package not found at expected path: ${dir}`); + } + return dir; } -function nativeEntriesForRid(zip) { - const nativePrefix = `runtimes/${RID}/native/`.toLowerCase(); - const runtimePrefix = `runtimes/${RID}/`.toLowerCase(); - return zip.getEntries().filter((e) => { - const p = e.entryName.toLowerCase(); - if (!p.endsWith(EXT) && !/\.so(\.\d+)+$/.test(p)) { - return false; - } - if (p.startsWith(nativePrefix)) return true; - if (p.startsWith(runtimePrefix)) { - const relative = p.slice(runtimePrefix.length); - return relative.length > 0 && !relative.includes('/'); - } - return false; - }); +function collectNativeFilesFromPackageDir(packageDir, rid, ext) { + const results = []; + const nativeDir = path.join(packageDir, "runtimes", rid, "native"); + if (fs.existsSync(nativeDir)) { + for (const name of fs.readdirSync(nativeDir)) { + if (isNativeFileName(name, ext)) results.push(path.join(nativeDir, name)); + } + } + const runtimeDir = path.join(packageDir, "runtimes", rid); + if (fs.existsSync(runtimeDir)) { + for (const name of fs.readdirSync(runtimeDir)) { + const full = path.join(runtimeDir, name); + if (isNativeFileName(name, ext) && fs.statSync(full).isFile()) results.push(full); + } + } + return results; +} + +function runDotnetMode(config, artifacts, binDir, platform) { + const missing = artifacts.filter((a) => !(a.expected && fs.existsSync(path.join(binDir, a.expected)))); + if (missing.length === 0) { + console.log(" All expected native files already present, skipping dotnet restore."); + return; + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "foundry-install-dotnet-")); + try { + const projectPath = path.join(tempDir, "restore.csproj"); + fs.writeFileSync(projectPath, generateRestoreProjectXml(missing)); + const packagesDir = path.join(tempDir, "packages"); + fs.mkdirSync(packagesDir, { recursive: true }); + + const args = buildDotnetRestoreArgs(config, { projectPath, packagesDir }); + console.log(" Running dotnet restore..."); + const result = spawnSync(config.dotnetCommand, args, { encoding: "utf8", shell: false }); + + if (result.error) { + if (result.error.code === "ENOENT") { + const cmd = config.dotnetCommand; + throw new Error( + `dotnet command not found: '${cmd}'. Install the .NET SDK or set FOUNDRY_LOCAL_DOTNET_COMMAND.`, + ); + } + throw result.error; + } + if (result.status !== 0) { + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + throw new Error(`dotnet restore failed (exit ${result.status}).\n${redactUrlsInText(output)}`.trim()); + } + + for (const artifact of missing) { + const pkgDir = findRestoredPackageDir(packagesDir, artifact.name, artifact.version); + const files = collectNativeFilesFromPackageDir(pkgDir, platform.rid, platform.ext); + if (files.length === 0) { + throw new Error( + `No native files found for RID '${platform.rid}' in ${artifact.name} ${artifact.version} (dotnet restore).`, + ); + } + for (const file of files) { + fs.copyFileSync(file, path.join(binDir, path.basename(file))); + console.log(` Staged ${path.basename(file)}`); + } + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } } -async function installPackage(artifact, tempDir, binDir) { - if (artifact.expected && fs.existsSync(path.join(binDir, artifact.expected))) { - console.log(` ${artifact.name}: ${artifact.expected} already present, skipping download.`); - return; +function buildNugetInstallArgs(config, { id, version, outputDir }) { + const args = [ + "install", + id, + "-Version", + version, + "-OutputDirectory", + outputDir, + "-NonInteractive", + "-DirectDownload", + "-DependencyVersion", + "Ignore", + ]; + if (config.configFile) { + args.push("-ConfigFile", config.configFile); + } else { + for (const feed of config.feeds) { + args.push("-Source", feed); } + } + return args; +} + +// nuget.exe install writes `.` under outputDir, but the casing follows the +// package's nuspec id rather than what was passed on the command line. Scan only the +// immediate children of outputDir (no recursion) and match case-insensitively. +function findNugetPackageDir(outputDir, id, version) { + const expected = `${id}.${version}`.toLowerCase(); + const match = fs + .readdirSync(outputDir, { withFileTypes: true }) + .find((e) => e.isDirectory() && e.name.toLowerCase() === expected); + if (!match) { + throw new Error(`Restored package not found under ${outputDir} (expected ${id}.${version}).`); + } + return path.join(outputDir, match.name); +} - let lastError; - for (let i = 0; i < FEEDS.length; i++) { - const feedUrl = FEEDS[i]; - const feedHost = new URL(feedUrl).host; - try { - const baseAddress = await getBaseAddress(feedUrl); - const nameLower = artifact.name.toLowerCase(); - const verLower = artifact.version.toLowerCase(); - const downloadUrl = `${baseAddress}${nameLower}/${verLower}/${nameLower}.${verLower}.nupkg`; - - const nupkgPath = path.join(tempDir, `${artifact.name}.${artifact.version}.nupkg`); - console.log(` Downloading ${artifact.name} ${artifact.version} from ${feedHost}...`); - await downloadFile(downloadUrl, nupkgPath); - - console.log(' Extracting...'); - const zip = new AdmZip(nupkgPath); - const entries = nativeEntriesForRid(zip); - if (entries.length === 0) { - throw new Error( - [ - `No native files found for RID '${RID}' in ${artifact.name} ${artifact.version}.`, - 'The package may not yet support this platform.', - 'Set FOUNDRY_LOCAL_SKIP_INSTALL=1 to bypass if you are building from source.', - ].join(' '), - ); - } - for (const entry of entries) { - zip.extractEntryTo(entry, binDir, false, true); - console.log(` Extracted ${entryFileName(entry)}`); - } - return; - } catch (err) { - lastError = err; - const reason = err instanceof Error ? err.message : String(err); - if (i < FEEDS.length - 1) { - console.warn(` ${artifact.name} ${artifact.version}: download from ${feedHost} failed (${reason}); trying next feed...`); - } +function runNugetMode(config, artifacts, binDir, platform) { + const missing = artifacts.filter((a) => !(a.expected && fs.existsSync(path.join(binDir, a.expected)))); + if (missing.length === 0) { + console.log(" All expected native files already present, skipping nuget install."); + return; + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "foundry-install-nuget-")); + try { + const packagesDir = path.join(tempDir, "packages"); + fs.mkdirSync(packagesDir, { recursive: true }); + + for (const artifact of missing) { + const args = buildNugetInstallArgs(config, { + id: artifact.name, + version: artifact.version, + outputDir: packagesDir, + }); + console.log(` Running nuget install for ${artifact.name} ${artifact.version}...`); + const result = spawnSync(config.nugetCommand, args, { encoding: "utf8", shell: false }); + + if (result.error) { + if (result.error.code === "ENOENT") { + const cmd = config.nugetCommand; + throw new Error( + `nuget command not found: '${cmd}'. Install the NuGet CLI or set FOUNDRY_LOCAL_NUGET_COMMAND.`, + ); } + throw result.error; + } + if (result.status !== 0) { + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + const detail = redactUrlsInText(output); + throw new Error( + `nuget install failed for ${artifact.name} ${artifact.version} (exit ${result.status}).\n${detail}`.trim(), + ); + } + + const pkgDir = findNugetPackageDir(packagesDir, artifact.name, artifact.version); + const files = collectNativeFilesFromPackageDir(pkgDir, platform.rid, platform.ext); + if (files.length === 0) { + throw new Error( + `No native files found for RID '${platform.rid}' in ${artifact.name} ${artifact.version} (nuget install).`, + ); + } + for (const file of files) { + fs.copyFileSync(file, path.join(binDir, path.basename(file))); + console.log(` Staged ${path.basename(file)}`); + } } - throw new Error( - `Failed to download ${artifact.name} ${artifact.version} from any configured feed (${FEEDS.map((f) => new URL(f).host).join(', ')}): ${lastError instanceof Error ? lastError.message : lastError}`, - ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } } // libfoundry_local records a versioned SONAME/install_name dependency on ORT @@ -240,45 +550,101 @@ async function installPackage(artifact, tempDir, binDir) { // on process exit. One physical file under both names keeps the process to a single ORT // image. Linux's loader dedups by soname, so it needs only the versioned name. function normalizeOrtLibName(binDir, ortVersion) { - let unversioned; - let versioned; - if (os.platform() === 'linux') { - unversioned = path.join(binDir, 'libonnxruntime.so'); - versioned = path.join(binDir, 'libonnxruntime.so.1'); - } else if (os.platform() === 'darwin') { - const major = ortVersion.split('.')[0]; - unversioned = path.join(binDir, 'libonnxruntime.dylib'); - versioned = path.join(binDir, `libonnxruntime.${major}.dylib`); - } else { - return; - } - if (!fs.existsSync(versioned) && fs.existsSync(unversioned)) { - fs.renameSync(unversioned, versioned); - console.log(` Renamed ${path.basename(unversioned)} -> ${path.basename(versioned)}`); - } - if (os.platform() === 'darwin' && fs.existsSync(versioned) && !fs.existsSync(unversioned)) { - fs.symlinkSync(path.basename(versioned), unversioned); - console.log(` Linked ${path.basename(unversioned)} -> ${path.basename(versioned)}`); - } + let unversioned; + let versioned; + if (os.platform() === "linux") { + unversioned = path.join(binDir, "libonnxruntime.so"); + versioned = path.join(binDir, "libonnxruntime.so.1"); + } else if (os.platform() === "darwin") { + const major = ortVersion.split(".")[0]; + unversioned = path.join(binDir, "libonnxruntime.dylib"); + versioned = path.join(binDir, `libonnxruntime.${major}.dylib`); + } else { + return; + } + if (!fs.existsSync(versioned) && fs.existsSync(unversioned)) { + fs.renameSync(unversioned, versioned); + console.log(` Renamed ${path.basename(unversioned)} -> ${path.basename(versioned)}`); + } + if (os.platform() === "darwin" && fs.existsSync(versioned) && !fs.existsSync(unversioned)) { + fs.symlinkSync(path.basename(versioned), unversioned); + console.log(` Linked ${path.basename(unversioned)} -> ${path.basename(versioned)}`); + } } -(async () => { - console.log(`[foundry-local] Installing native runtime libraries for ${RID} into ${BIN_DIR}...`); - fs.mkdirSync(BIN_DIR, { recursive: true }); +async function main() { + if (process.env.FOUNDRY_LOCAL_SKIP_INSTALL === "1") { + console.log("[foundry-local] FOUNDRY_LOCAL_SKIP_INSTALL=1 set; skipping native runtime download."); + return 0; + } - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'foundry-install-')); - try { - for (const artifact of ARTIFACTS) { - await installPackage(artifact, tempDir, BIN_DIR); - } - normalizeOrtLibName(BIN_DIR, ortVersion); - console.log('[foundry-local] Native runtime install complete.'); - } catch (err) { - console.error('[foundry-local] Installation failed:', err instanceof Error ? err.message : err); - process.exit(1); - } finally { - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch {} - } -})(); + const platform = detectPlatform(); + if (!platform) { + console.warn( + `[foundry-local] Unsupported platform: ${os.platform()}-${os.arch()}. Skipping native runtime install.`, + ); + return 0; + } + + const pkgRoot = path.resolve(__dirname, ".."); + const deps = loadDeps(pkgRoot); + const artifacts = buildArtifacts(deps, platform); + const binDir = path.join(pkgRoot, "prebuilds", platform.platformKey); + const config = readConfig(process.env); + + console.log( + `[foundry-local] Installing native runtime libraries for ${platform.rid} into ${binDir} (mode: ${config.mode})...`, + ); + fs.mkdirSync(binDir, { recursive: true }); + + if (config.mode === "http") { + await runHttpMode(config, artifacts, binDir, platform); + } else if (config.mode === "dotnet") { + runDotnetMode(config, artifacts, binDir, platform); + } else { + runNugetMode(config, artifacts, binDir, platform); + } + + normalizeOrtLibName(binDir, deps.onnxruntime.version); + console.log("[foundry-local] Native runtime install complete."); + return 0; +} + +module.exports = { + // config + readConfig, + DEFAULT_FEEDS, + // url helpers + redactUrl, + redactUrlsInText, + safeErrorMessage, + // platform / artifacts + detectPlatform, + loadDeps, + buildArtifacts, + // http mode + downloadWithRetryAndRedirects, + nativeEntriesForRid, + // dotnet mode + generateRestoreProjectXml, + buildDotnetRestoreArgs, + findRestoredPackageDir, + collectNativeFilesFromPackageDir, + // nuget mode + buildNugetInstallArgs, + findNugetPackageDir, + // shared + normalizeOrtLibName, + main, +}; + +if (require.main === module) { + main() + .then((code) => { + process.exitCode = code ?? 0; + }) + .catch((err) => { + console.error("[foundry-local] Installation failed:", safeErrorMessage(err)); + process.exitCode = 1; + }); +} diff --git a/sdk_v2/js/test/install-native.test.ts b/sdk_v2/js/test/install-native.test.ts new file mode 100644 index 000000000..1be992d8d --- /dev/null +++ b/sdk_v2/js/test/install-native.test.ts @@ -0,0 +1,489 @@ +// Pure installer tests: no network calls or dotnet invocation. +import { EventEmitter } from "node:events"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { platform, tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const here = fileURLToPath(new URL(".", import.meta.url)); +const require = createRequire(import.meta.url); + +// biome-ignore lint/suspicious/noExplicitAny: .cjs module has no type declarations (see test/preload-addon.test.ts). +const installNative = require(join(here, "..", "script", "install-native.cjs")) as any; + +const { + readConfig, + DEFAULT_FEEDS, + redactUrl, + redactUrlsInText, + downloadWithRetryAndRedirects, + generateRestoreProjectXml, + buildDotnetRestoreArgs, + findRestoredPackageDir, + collectNativeFilesFromPackageDir, + buildNugetInstallArgs, + findNugetPackageDir, + main, +} = installNative; + +const ORIGINAL_ENV = { ...process.env }; + +function resetEnv(): void { + for (const key of Object.keys(process.env)) { + if (key.startsWith("FOUNDRY_LOCAL_")) delete process.env[key]; + } +} + +beforeEach(() => { + resetEnv(); +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("readConfig", () => { + it("defaults to http mode with the public default feeds", () => { + const config = readConfig(process.env); + expect(config.mode).toBe("http"); + expect(config.feeds).toEqual(DEFAULT_FEEDS); + expect(config.configFile).toBeUndefined(); + expect(config.dotnetCommand).toBe("dotnet"); + expect(config.nugetCommand).toBe(platform() === "win32" ? "nuget.exe" : "nuget"); + }); + + it("accepts explicit http, dotnet, and nuget modes", () => { + process.env.FOUNDRY_LOCAL_NUGET_MODE = "http"; + expect(readConfig(process.env).mode).toBe("http"); + + process.env.FOUNDRY_LOCAL_NUGET_MODE = "dotnet"; + expect(readConfig(process.env).mode).toBe("dotnet"); + + process.env.FOUNDRY_LOCAL_NUGET_MODE = "nuget"; + expect(readConfig(process.env).mode).toBe("nuget"); + }); + + it("rejects an invalid mode value", () => { + process.env.FOUNDRY_LOCAL_NUGET_MODE = "ftp"; + expect(() => readConfig(process.env)).toThrow(/Invalid FOUNDRY_LOCAL_NUGET_MODE/); + }); + + it("a custom FOUNDRY_LOCAL_NUGET_FEEDS list replaces the public defaults", () => { + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = "https://feed.example/index.json;https://feed2.example/index.json"; + const config = readConfig(process.env); + expect(config.feeds).toEqual(["https://feed.example/index.json", "https://feed2.example/index.json"]); + }); + + it("trims whitespace and drops empty entries from the feed list", () => { + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = " https://feed.example/index.json ; ;https://feed2.example/index.json"; + const config = readConfig(process.env); + expect(config.feeds).toEqual(["https://feed.example/index.json", "https://feed2.example/index.json"]); + }); + + it("rejects a feeds list that is set but empty after trimming", () => { + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = " ; ;"; + expect(() => readConfig(process.env)).toThrow(/contains no feed URLs/); + }); + + it("rejects an explicitly empty feeds value instead of restoring defaults", () => { + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = ""; + expect(() => readConfig(process.env)).toThrow(/contains no feed URLs/); + }); + + it("rejects an invalid URL in the feed list", () => { + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = "not-a-url"; + expect(() => readConfig(process.env)).toThrow(/invalid URL/); + }); + + it("rejects feed URLs with embedded credentials", () => { + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = "https://user:secret@feed.example/index.json"; + expect(() => readConfig(process.env)).toThrow(/invalid URL/); + }); + + it("http mode requires HTTPS feeds", () => { + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = "http://feed.example/index.json"; + expect(() => readConfig(process.env)).toThrow(/must use HTTPS in http mode/); + }); + + it("dotnet mode allows non-HTTPS feeds (dotnet/NuGet owns transport trust there)", () => { + process.env.FOUNDRY_LOCAL_NUGET_MODE = "dotnet"; + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = "http://feed.example/index.json"; + expect(() => readConfig(process.env)).not.toThrow(); + }); + + it("nuget mode allows non-HTTPS feeds (nuget.exe owns transport trust there)", () => { + process.env.FOUNDRY_LOCAL_NUGET_MODE = "nuget"; + process.env.FOUNDRY_LOCAL_NUGET_FEEDS = "http://feed.example/index.json"; + expect(() => readConfig(process.env)).not.toThrow(); + }); + + it("rejects FOUNDRY_LOCAL_NUGET_CONFIG in http mode", () => { + process.env.FOUNDRY_LOCAL_NUGET_CONFIG = "C:\\NuGet.config"; + expect(() => readConfig(process.env)).toThrow(/FOUNDRY_LOCAL_NUGET_CONFIG is only valid/); + }); + + it("rejects FOUNDRY_LOCAL_DOTNET_COMMAND in http mode", () => { + process.env.FOUNDRY_LOCAL_DOTNET_COMMAND = "dotnet8"; + expect(() => readConfig(process.env)).toThrow(/FOUNDRY_LOCAL_DOTNET_COMMAND is only valid/); + }); + + it("rejects FOUNDRY_LOCAL_NUGET_COMMAND in http mode", () => { + process.env.FOUNDRY_LOCAL_NUGET_COMMAND = "custom-nuget"; + expect(() => readConfig(process.env)).toThrow(/FOUNDRY_LOCAL_NUGET_COMMAND is only valid/); + }); + + it("rejects FOUNDRY_LOCAL_NUGET_COMMAND in dotnet mode", () => { + process.env.FOUNDRY_LOCAL_NUGET_MODE = "dotnet"; + process.env.FOUNDRY_LOCAL_NUGET_COMMAND = "custom-nuget"; + expect(() => readConfig(process.env)).toThrow(/FOUNDRY_LOCAL_NUGET_COMMAND is only valid/); + }); + + it("rejects FOUNDRY_LOCAL_DOTNET_COMMAND in nuget mode", () => { + process.env.FOUNDRY_LOCAL_NUGET_MODE = "nuget"; + process.env.FOUNDRY_LOCAL_DOTNET_COMMAND = "dotnet8"; + expect(() => readConfig(process.env)).toThrow(/FOUNDRY_LOCAL_DOTNET_COMMAND is only valid/); + }); + + it("accepts FOUNDRY_LOCAL_NUGET_CONFIG and a custom dotnet command in dotnet mode", () => { + process.env.FOUNDRY_LOCAL_NUGET_MODE = "dotnet"; + process.env.FOUNDRY_LOCAL_NUGET_CONFIG = "C:\\NuGet.config"; + process.env.FOUNDRY_LOCAL_DOTNET_COMMAND = "dotnet8"; + const config = readConfig(process.env); + expect(config.configFile).toBe("C:\\NuGet.config"); + expect(config.dotnetCommand).toBe("dotnet8"); + }); + + it("accepts FOUNDRY_LOCAL_NUGET_CONFIG and a custom nuget command in nuget mode", () => { + process.env.FOUNDRY_LOCAL_NUGET_MODE = "nuget"; + process.env.FOUNDRY_LOCAL_NUGET_CONFIG = "C:\\NuGet.config"; + process.env.FOUNDRY_LOCAL_NUGET_COMMAND = "C:\\tools\\nuget\\nuget.exe"; + const config = readConfig(process.env); + expect(config.mode).toBe("nuget"); + expect(config.configFile).toBe("C:\\NuGet.config"); + expect(config.nugetCommand).toBe("C:\\tools\\nuget\\nuget.exe"); + }); +}); + +describe("redactUrl", () => { + it("strips the query string", () => { + expect(redactUrl("https://example.com/pkg.nupkg?sv=2021&sig=SECRET")).toBe("https://example.com/pkg.nupkg"); + }); + + describe("redactUrlsInText", () => { + it("redacts query strings from URLs in command output", () => { + const output = "error downloading https://blob.example/pkg.nupkg?sig=SECRET and retrying"; + expect(redactUrlsInText(output)).toBe("error downloading https://blob.example/pkg.nupkg and retrying"); + }); + }); + + it("strips the fragment", () => { + expect(redactUrl("https://example.com/pkg.nupkg#token=SECRET")).toBe("https://example.com/pkg.nupkg"); + }); + + it("strips embedded credentials", () => { + const url = `https://${"user"}:${"secret"}@example.com/pkg.nupkg`; + const redacted = redactUrl(url); + expect(redacted).toBe("https://example.com/pkg.nupkg"); + expect(redacted).not.toContain("user"); + expect(redacted).not.toContain("secret"); + }); + + it("leaves a URL with no query/fragment unchanged", () => { + expect(redactUrl("https://example.com/pkg.nupkg")).toBe("https://example.com/pkg.nupkg"); + }); + + it("returns non-URL input as-is rather than throwing", () => { + expect(redactUrl("not a url")).toBe("not a url"); + }); +}); + +describe("downloadWithRetryAndRedirects", () => { + it("follows redirects (absolute and relative) and returns the final body", async () => { + const requests: string[] = []; + const responses = [ + { statusCode: 302, location: "https://blob.example/package" }, + { statusCode: 302, location: "/final" }, + { statusCode: 200, body: "{}" }, + ]; + const request = ( + url: string, + _options: unknown, + callback: (response: Readable & { statusCode: number; headers: { location?: string | undefined } }) => void, + ) => { + requests.push(url); + const next = responses.shift(); + if (!next) throw new Error("Unexpected request"); + const response = new Readable({ + read() { + if (next.body) this.push(next.body); + this.push(null); + }, + }) as Readable & { statusCode: number; headers: { location?: string | undefined } }; + response.statusCode = next.statusCode; + response.headers = { location: next.location }; + queueMicrotask(() => callback(response)); + return new EventEmitter(); + }; + + const result = await downloadWithRetryAndRedirects("https://feed.example/index.json", { request }); + + expect(result).toBe("{}"); + expect(requests).toEqual([ + "https://feed.example/index.json", + "https://blob.example/package", + "https://blob.example/final", + ]); + }); +}); + +describe("generateRestoreProjectXml", () => { + it("includes bracketed exact versions for every artifact", () => { + const xml = generateRestoreProjectXml([ + { name: "Microsoft.ML.OnnxRuntime", version: "1.28.0", expected: "onnxruntime.dll" }, + { name: "Microsoft.ML.OnnxRuntimeGenAI.Foundry", version: "0.15.1", expected: "onnxruntime-genai.dll" }, + ]); + expect(xml).toContain(''); + expect(xml).toContain(''); + }); + + it("targets net8.0", () => { + const xml = generateRestoreProjectXml([{ name: "A", version: "1.0.0", expected: "a.dll" }]); + expect(xml).toContain("net8.0"); + }); +}); + +describe("buildDotnetRestoreArgs", () => { + const config = { feeds: ["https://a.example/index.json", "https://b.example/index.json"] }; + + it("passes --source per feed and no --configfile when no config file is set", () => { + const args = buildDotnetRestoreArgs( + { ...config, configFile: undefined }, + { + projectPath: "proj.csproj", + packagesDir: "pkgs", + }, + ); + expect(args).toEqual([ + "restore", + "proj.csproj", + "--packages", + "pkgs", + "--no-cache", + "--source", + "https://a.example/index.json", + "--source", + "https://b.example/index.json", + ]); + }); + + it("passes --configfile and no --source args when a config file is set", () => { + const args = buildDotnetRestoreArgs( + { ...config, configFile: "NuGet.config" }, + { + projectPath: "proj.csproj", + packagesDir: "pkgs", + }, + ); + expect(args).toEqual([ + "restore", + "proj.csproj", + "--packages", + "pkgs", + "--no-cache", + "--configfile", + "NuGet.config", + ]); + }); + + it("never includes --runtime", () => { + const args = buildDotnetRestoreArgs( + { ...config, configFile: undefined }, + { + projectPath: "proj.csproj", + packagesDir: "pkgs", + }, + ); + expect(args).not.toContain("--runtime"); + }); +}); + +describe("findRestoredPackageDir", () => { + let packagesDir: string; + + beforeEach(() => { + packagesDir = mkdtempSync(join(tmpdir(), "install-native-test-")); + }); + + afterEach(() => { + rmSync(packagesDir, { recursive: true, force: true }); + }); + + it("finds the lowercased id/version directory", () => { + const dir = join(packagesDir, "microsoft.ml.onnxruntime", "1.28.0"); + mkdirSync(dir, { recursive: true }); + expect(findRestoredPackageDir(packagesDir, "Microsoft.ML.OnnxRuntime", "1.28.0")).toBe(dir); + }); + + it("throws when the expected package directory is missing", () => { + expect(() => findRestoredPackageDir(packagesDir, "Nonexistent.Package", "9.9.9")).toThrow( + /Restored package not found/, + ); + }); +}); + +describe("buildNugetInstallArgs", () => { + const config = { feeds: ["https://a.example/index.json", "https://b.example/index.json"] }; + + it("passes exact package-only install flags and each configured source", () => { + const args = buildNugetInstallArgs( + { ...config, configFile: undefined }, + { id: "Microsoft.ML.OnnxRuntime", version: "1.28.0", outputDir: "pkgs" }, + ); + expect(args).toEqual([ + "install", + "Microsoft.ML.OnnxRuntime", + "-Version", + "1.28.0", + "-OutputDirectory", + "pkgs", + "-NonInteractive", + "-DirectDownload", + "-DependencyVersion", + "Ignore", + "-Source", + "https://a.example/index.json", + "-Source", + "https://b.example/index.json", + ]); + }); + + it("passes -ConfigFile and no -Source args when a config file is set", () => { + const args = buildNugetInstallArgs( + { ...config, configFile: "NuGet.config" }, + { id: "Microsoft.ML.OnnxRuntimeGenAI.Foundry", version: "0.15.1", outputDir: "pkgs" }, + ); + expect(args).toEqual([ + "install", + "Microsoft.ML.OnnxRuntimeGenAI.Foundry", + "-Version", + "0.15.1", + "-OutputDirectory", + "pkgs", + "-NonInteractive", + "-DirectDownload", + "-DependencyVersion", + "Ignore", + "-ConfigFile", + "NuGet.config", + ]); + }); + + it("a custom feed list (not the public defaults) fully replaces -Source values", () => { + const args = buildNugetInstallArgs( + { feeds: ["https://private.example/nuget/v3/index.json"], configFile: undefined }, + { id: "A", version: "1.0.0", outputDir: "pkgs" }, + ); + expect(args.filter((a: string) => a === "-Source")).toHaveLength(1); + expect(args).toContain("https://private.example/nuget/v3/index.json"); + expect(args).not.toContain("https://api.nuget.org/v3/index.json"); + }); + + it("keeps real feed URLs in spawn args but still redacts them when logged", () => { + const feedWithSecret = "https://feed.example/index.json?pat=SECRET123"; + const args = buildNugetInstallArgs( + { feeds: [feedWithSecret], configFile: undefined }, + { + id: "A", + version: "1.0.0", + outputDir: "pkgs", + }, + ); + expect(args.join(" ")).toContain("SECRET123"); + expect(redactUrlsInText(args.join(" "))).not.toContain("SECRET123"); + }); +}); + +describe("findNugetPackageDir", () => { + let outputDir: string; + + beforeEach(() => { + outputDir = mkdtempSync(join(tmpdir(), "install-native-test-nuget-")); + }); + + afterEach(() => { + rmSync(outputDir, { recursive: true, force: true }); + }); + + it("finds the id.version directory using nuget's original casing", () => { + const dir = join(outputDir, "Microsoft.ML.OnnxRuntime.1.28.0"); + mkdirSync(dir, { recursive: true }); + expect(findNugetPackageDir(outputDir, "Microsoft.ML.OnnxRuntime", "1.28.0")).toBe(dir); + }); + + it("matches case-insensitively regardless of the casing nuget.exe produced", () => { + const dir = join(outputDir, "microsoft.ml.onnxruntimegenai.foundry.0.15.1"); + mkdirSync(dir, { recursive: true }); + expect(findNugetPackageDir(outputDir, "Microsoft.ML.OnnxRuntimeGenAI.Foundry", "0.15.1")).toBe(dir); + }); + + it("only looks at immediate children of outputDir, not nested dependency package folders", () => { + // Simulates nuget.exe restoring a transitive dependency alongside the requested package — + // only the exact id.version match at the root should be returned. + mkdirSync(join(outputDir, "Some.Other.Dependency.2.0.0"), { recursive: true }); + const dir = join(outputDir, "Microsoft.ML.OnnxRuntime.1.28.0"); + mkdirSync(join(dir, "runtimes", "win-x64", "native"), { recursive: true }); + expect(findNugetPackageDir(outputDir, "Microsoft.ML.OnnxRuntime", "1.28.0")).toBe(dir); + }); + + it("throws when the expected package directory is missing", () => { + expect(() => findNugetPackageDir(outputDir, "Nonexistent.Package", "9.9.9")).toThrow(/Restored package not found/); + }); +}); + +describe("collectNativeFilesFromPackageDir", () => { + let packageDir: string; + + beforeEach(() => { + packageDir = mkdtempSync(join(tmpdir(), "install-native-test-pkg-")); + }); + + afterEach(() => { + rmSync(packageDir, { recursive: true, force: true }); + }); + + it("collects files under runtimes//native/", () => { + const nativeDir = join(packageDir, "runtimes", "win-x64", "native"); + mkdirSync(nativeDir, { recursive: true }); + writeFileSync(join(nativeDir, "onnxruntime.dll"), ""); + writeFileSync(join(nativeDir, "readme.txt"), ""); + + const files = collectNativeFilesFromPackageDir(packageDir, "win-x64", ".dll"); + expect(files).toEqual([join(nativeDir, "onnxruntime.dll")]); + }); + + it("collects loose files directly under runtimes// but not nested subfolders", () => { + const runtimeDir = join(packageDir, "runtimes", "linux-x64"); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync(join(runtimeDir, "libonnxruntime.so.1"), ""); + mkdirSync(join(runtimeDir, "lib"), { recursive: true }); + writeFileSync(join(runtimeDir, "lib", "libonnxruntime.so.1"), ""); + + const files = collectNativeFilesFromPackageDir(packageDir, "linux-x64", ".so"); + expect(files).toEqual([join(runtimeDir, "libonnxruntime.so.1")]); + }); + + it("returns an empty array when the RID has no matching directory", () => { + expect(collectNativeFilesFromPackageDir(packageDir, "osx-arm64", ".dylib")).toEqual([]); + }); +}); + +describe("main() — FOUNDRY_LOCAL_SKIP_INSTALL", () => { + it("returns 0 immediately without touching the filesystem or network", async () => { + process.env.FOUNDRY_LOCAL_SKIP_INSTALL = "1"; + await expect(main()).resolves.toBe(0); + }); +});