diff --git a/INSTALL.md b/INSTALL.md index 4729c58..1561192 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -241,6 +241,28 @@ corporate registry. Use `npm ci`, not `npm install`, and do not regenerate the lockfile during installation. Keep the entire checkout, `.compliance`, and dependency legal files. +### Networks that block registry.npmjs.org + +The installers run a portable Node.js runtime that is unpacked into the +installation root. Because Node.js's portable archives ship no builtin npmrc, +npm would resolve its global configuration inside that throwaway runtime +directory and ignore the registry configured for the machine. The installers +therefore locate the machine's real global npmrc and pass it to the portable npm +so that `replace-registry-host` maps the lockfile's canonical npmjs URLs onto the +configured mirror. Machines with no npm configuration are unaffected and continue +to use `registry.npmjs.org`. + +Configure a mirror once with: + +```sh +npm config set registry --location=global +``` + +To point a single installation at a specific registry without changing any npm +configuration, set `SKILL_RECORDER_NPM_REGISTRY` to an HTTPS registry URL before +running the installer. The lockfile's integrity hashes are verified whichever +registry serves the packages, so a mirror cannot substitute different content. + ## Licensing boundary The source channels distribute this repository's MIT-licensed source. The diff --git a/install.ps1 b/install.ps1 index 47d70b5..98daf58 100644 --- a/install.ps1 +++ b/install.ps1 @@ -276,6 +276,76 @@ function Invoke-CheckedCommand { } } +function Resolve-MachineNpmConfigPath { + param([string[]]$CandidatePaths) + + foreach ($candidate in $CandidatePaths) { + if ([string]::IsNullOrWhiteSpace($candidate)) { + continue + } + $trimmed = $candidate.Trim().Trim('"') + if ($trimmed -in @("undefined", "null")) { + continue + } + # A malformed npm configuration must never block a portable installation. + try { + if (Test-Path -LiteralPath $trimmed -PathType Leaf) { + return [IO.Path]::GetFullPath($trimmed) + } + } catch { + continue + } + } + return $null +} + +function Get-SystemNpmGlobalConfigPath { + $npmCommand = @( + Get-Command npm -CommandType Application -ErrorAction SilentlyContinue + ) | Select-Object -First 1 + if (-not $npmCommand) { + return $null + } + + $previousPreference = $ErrorActionPreference + $output = @() + $exitCode = 1 + try { + $ErrorActionPreference = "Continue" + $output = @(& $npmCommand.Source "config" "get" "globalconfig" 2>$null) + $exitCode = $LASTEXITCODE + } catch { + $output = @() + $exitCode = 1 + } finally { + $ErrorActionPreference = $previousPreference + $global:LASTEXITCODE = 0 + } + + if ($exitCode -ne 0 -or $output.Count -eq 0) { + return $null + } + return ([string]$output[-1]).Trim() +} + +function Get-MachineNpmConfigPath { + # The portable Node.js archive ships no builtin npmrc, so npm resolves its + # global config inside the throwaway runtime directory and silently ignores a + # registry configured for this machine. Point npm back at the real file so + # networks that block registry.npmjs.org still install through their mirror. + $candidates = New-Object System.Collections.Generic.List[string] + + $reported = Get-SystemNpmGlobalConfigPath + if (-not [string]::IsNullOrWhiteSpace($reported)) { + $candidates.Add($reported) + } + if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { + $candidates.Add((Join-Path $env:APPDATA "npm\etc\npmrc")) + } + + return Resolve-MachineNpmConfigPath -CandidatePaths $candidates.ToArray() +} + function Get-WindowsArchitecture { $architecture = [Environment]::GetEnvironmentVariable( "PROCESSOR_ARCHITECTURE", @@ -606,10 +676,35 @@ if (Test-Path -LiteralPath $sourceDirectory -PathType Container) { "scripts\run-reviewed-electron.mjs" ) + $registryOverride = $env:SKILL_RECORDER_NPM_REGISTRY + if (-not [string]::IsNullOrWhiteSpace($registryOverride)) { + $registryOverride = $registryOverride.Trim() + $parsedRegistry = $null + if ( + -not [Uri]::TryCreate($registryOverride, [UriKind]::Absolute, [ref]$parsedRegistry) -or + $parsedRegistry.Scheme -ne "https" + ) { + throw "SKILL_RECORDER_NPM_REGISTRY must be an absolute HTTPS URL: $registryOverride" + } + } else { + $registryOverride = $null + } + $environmentOverrides = [ordered]@{ PATH = "$($runtime.Root);$env:PATH" NPM_CONFIG_ALLOW_SCRIPTS = $null } + if ($registryOverride) { + Write-Step "Using the npm registry requested by SKILL_RECORDER_NPM_REGISTRY." + $environmentOverrides["NPM_CONFIG_REGISTRY"] = $registryOverride + } else { + $machineNpmConfig = Get-MachineNpmConfigPath + if ($machineNpmConfig) { + Write-Step "Applying this machine's npm configuration from $machineNpmConfig." + $environmentOverrides["NPM_CONFIG_GLOBALCONFIG"] = $machineNpmConfig + } + } + $originalEnvironment = @{} foreach ($entry in $environmentOverrides.GetEnumerator()) { $originalEnvironment[$entry.Key] = [Environment]::GetEnvironmentVariable( @@ -642,17 +737,35 @@ if (Test-Path -LiteralPath $sourceDirectory -PathType Container) { -Description "lockfile portability validation" Write-Step "Installing lockfile-pinned dependencies through the configured npm registry." - Invoke-CheckedCommand ` - -FilePath $runtime.Npm ` - -Arguments @( - "ci", - "--no-audit", - "--no-fund", - "--ignore-scripts=false", - "--dangerously-allow-all-scripts=false", - "--strict-allow-scripts" - ) ` - -Description "npm ci" + $registryOutput = @(& $runtime.Npm config get registry) + $effectiveRegistry = "the configured npm registry" + if ($LASTEXITCODE -eq 0 -and $registryOutput.Count -gt 0) { + $effectiveRegistry = ([string]$registryOutput[-1]).Trim() + Write-Step "Dependencies will be downloaded from $effectiveRegistry." + } + $global:LASTEXITCODE = 0 + + try { + Invoke-CheckedCommand ` + -FilePath $runtime.Npm ` + -Arguments @( + "ci", + "--no-audit", + "--no-fund", + "--ignore-scripts=false", + "--dangerously-allow-all-scripts=false", + "--strict-allow-scripts" + ) ` + -Description "npm ci" + } catch { + throw ( + "$($_.Exception.Message) Dependencies were requested from $effectiveRegistry. " + + "If your network blocks that registry, configure a compatible mirror with " + + "'npm config set registry --location=global', or set " + + "SKILL_RECORDER_NPM_REGISTRY= before running the installer again. " + + "The lockfile's integrity hashes are verified whichever registry serves the packages." + ) + } $electronDistribution = Assert-ReviewedElectronDistribution ` -SourceDirectory $buildDirectory ` diff --git a/install.sh b/install.sh index 4390cc8..eb54500 100755 --- a/install.sh +++ b/install.sh @@ -108,6 +108,22 @@ checksum_from_manifest() { awk -v name="$file_name" '$2 == name || $2 == "*" name { print tolower($1); exit }' "$manifest" } +detect_machine_npm_config() { + # The portable Node.js archive ships no builtin npmrc, so npm resolves its + # global config inside the throwaway runtime directory and silently ignores a + # registry configured for this machine. Capture the real path before the + # portable runtime is prepended to PATH so mirrored registries keep working. + local candidate="" + if have npm; then + candidate="$(npm config get globalconfig 2>/dev/null | tail -n 1 | tr -d '\r')" || candidate="" + fi + case "$candidate" in + ""|undefined|null) return 0 ;; + esac + [ -f "$candidate" ] || return 0 + printf '%s' "$candidate" +} + install_node_runtime() { local channel="https://nodejs.org/dist/latest-v24.x" local sums="$WORK_DIR/node-SHASUMS256.txt" @@ -266,18 +282,46 @@ build_source_install() { export NPM_CONFIG_CACHE="$INSTALL_ROOT/npm-cache" unset NPM_CONFIG_ALLOW_SCRIPTS npm_config_allow_scripts + if [ -n "${SKILL_RECORDER_NPM_REGISTRY:-}" ]; then + case "$SKILL_RECORDER_NPM_REGISTRY" in + https://*) ;; + *) + die "SKILL_RECORDER_NPM_REGISTRY must be an absolute HTTPS URL: $SKILL_RECORDER_NPM_REGISTRY." + ;; + esac + info "Using the npm registry requested by SKILL_RECORDER_NPM_REGISTRY." + export NPM_CONFIG_REGISTRY="$SKILL_RECORDER_NPM_REGISTRY" + elif [ -n "${MACHINE_NPM_CONFIG:-}" ]; then + info "Applying this machine's npm configuration from $MACHINE_NPM_CONFIG." + export NPM_CONFIG_GLOBALCONFIG="$MACHINE_NPM_CONFIG" + fi + info "Validating portable dependency policy." local npm_version npm_version="$("$NPM" --version)" || die "Could not determine the bundled npm version." "$NODE" "scripts/check-lockfile-portability.mjs" --npm-version "$npm_version" info "Installing lockfile-pinned dependencies through the configured npm registry." + local effective_registry + effective_registry="$("$NPM" config get registry 2>/dev/null | tail -n 1 | tr -d '\r')" || + effective_registry="" + [ -n "$effective_registry" ] || effective_registry="the configured npm registry" + info "Dependencies will be downloaded from $effective_registry." + "$NPM" ci \ --no-audit \ --no-fund \ --ignore-scripts=false \ --dangerously-allow-all-scripts=false \ - --strict-allow-scripts + --strict-allow-scripts || + die "$( + printf '%s' \ + "npm ci failed. Dependencies were requested from $effective_registry. " \ + "If your network blocks that registry, configure a compatible mirror with " \ + "'npm config set registry --location=global', or set " \ + "SKILL_RECORDER_NPM_REGISTRY= before running the installer again. " \ + "The lockfile's integrity hashes are verified whichever registry serves the packages." + )" local policy_key="$PLATFORM-$ARCHITECTURE" local electron_version reviewed_hash @@ -470,6 +514,8 @@ write_launcher() { fi } +MACHINE_NPM_CONFIG="$(detect_machine_npm_config)" + install_node_runtime SOURCE_DIR="$VERSIONS_ROOT/$COMMIT" diff --git a/scripts/install-windows.test.ps1 b/scripts/install-windows.test.ps1 index d156dcc..de7f053 100644 --- a/scripts/install-windows.test.ps1 +++ b/scripts/install-windows.test.ps1 @@ -23,7 +23,8 @@ if ($parseErrors.Count -ne 0) { $helperNames = @( "ConvertTo-ExtendedLengthPath", "Move-DirectoryTree", - "Remove-DirectoryTree" + "Remove-DirectoryTree", + "Resolve-MachineNpmConfigPath" ) $functionDefinitions = @( $installerAst.FindAll( @@ -50,6 +51,40 @@ if ($uncPath -ne "\\?\UNC\server\share\folder") { throw "Extended-length UNC conversion returned an unexpected path: $uncPath" } +$npmConfigRoot = Join-Path ( + [IO.Path]::GetTempPath() +) ("skill-recorder-npmrc-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $npmConfigRoot -Force | Out-Null +try { + $machineNpmrc = Join-Path $npmConfigRoot "npmrc" + Set-Content -LiteralPath $machineNpmrc -Value "registry=https://example.invalid/npm/" -Encoding ASCII + $missingNpmrc = Join-Path $npmConfigRoot "missing\npmrc" + + $resolved = Resolve-MachineNpmConfigPath -CandidatePaths @($missingNpmrc, $machineNpmrc) + if ($resolved -ne [IO.Path]::GetFullPath($machineNpmrc)) { + throw "Resolve-MachineNpmConfigPath skipped the existing npmrc: $resolved" + } + + # npm prints "undefined" when no global config is configured; it is not a path. + $placeholders = Resolve-MachineNpmConfigPath -CandidatePaths @("undefined", "null", "", $null) + if ($null -ne $placeholders) { + throw "Resolve-MachineNpmConfigPath accepted a placeholder value: $placeholders" + } + + # Installs on machines without any npm configuration must stay on the default registry. + $absent = Resolve-MachineNpmConfigPath -CandidatePaths @($missingNpmrc) + if ($null -ne $absent) { + throw "Resolve-MachineNpmConfigPath returned a nonexistent npmrc: $absent" + } + + $quoted = Resolve-MachineNpmConfigPath -CandidatePaths @(('"' + $machineNpmrc + '" ')) + if ($quoted -ne [IO.Path]::GetFullPath($machineNpmrc)) { + throw "Resolve-MachineNpmConfigPath did not normalize a quoted npm path: $quoted" + } +} finally { + Remove-Item -LiteralPath $npmConfigRoot -Recurse -Force +} + $testRoot = Join-Path ( [IO.Path]::GetTempPath() ) ("skill-recorder-installer-" + [guid]::NewGuid().ToString("N"))