@@ -14,45 +14,56 @@ namespace Installer.Core;
1414
1515/// <summary>
1616/// Installs community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit)
17- /// from GitHub. Requires an HttpClient — create one instance and dispose when done.
17+ /// from a local community/ directory or GitHub. Local files are checked first — if
18+ /// present, the network is not used. This supports air-gapped installations.
1819/// </summary>
1920public sealed class DependencyInstaller : IDisposable
2021{
2122 private readonly HttpClient _httpClient ;
23+ private readonly string ? _communityDirectory ;
2224 private bool _disposed ;
2325
24- public DependencyInstaller ( )
26+ /// <param name="communityDirectory">
27+ /// Optional path to a community/ directory containing pre-downloaded SQL files.
28+ /// When provided and files exist, they are used instead of downloading from GitHub.
29+ /// </param>
30+ public DependencyInstaller ( string ? communityDirectory = null )
2531 {
2632 _httpClient = new HttpClient
2733 {
2834 Timeout = TimeSpan . FromSeconds ( 30 )
2935 } ;
36+ _communityDirectory = communityDirectory ;
3037 }
3138
3239 /// <summary>
33- /// Install community dependencies from GitHub into the PerformanceMonitor database.
40+ /// Install community dependencies into the PerformanceMonitor database.
41+ /// Checks the community/ directory first, falls back to GitHub download.
3442 /// Returns the number of successfully installed dependencies.
3543 /// </summary>
3644 public async Task < int > InstallDependenciesAsync (
3745 string connectionString ,
3846 IProgress < InstallationProgress > ? progress = null ,
3947 CancellationToken cancellationToken = default )
4048 {
41- var dependencies = new List < ( string Name , string Url , string Description ) >
49+ var dependencies = new List < ( string Name , string Url , string LocalFile , string Description ) >
4250 {
4351 (
4452 "sp_WhoIsActive" ,
4553 "https://raw.githubusercontent.com/amachanic/sp_whoisactive/refs/heads/master/sp_WhoIsActive.sql" ,
54+ "sp_WhoIsActive.sql" ,
4655 "Query activity monitoring by Adam Machanic (GPLv3)"
4756 ) ,
4857 (
4958 "DarlingData" ,
5059 "https://raw.githubusercontent.com/erikdarlingdata/DarlingData/main/Install-All/DarlingData.sql" ,
60+ "DarlingData.sql" ,
5161 "sp_HealthParser, sp_HumanEventsBlockViewer by Erik Darling (MIT)"
5262 ) ,
5363 (
5464 "First Responder Kit" ,
5565 "https://raw.githubusercontent.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/refs/heads/main/Install-All-Scripts.sql" ,
66+ "Install-All-Scripts.sql" ,
5667 "sp_BlitzLock and diagnostic tools by Brent Ozar Unlimited (MIT)"
5768 )
5869 } ;
@@ -65,7 +76,7 @@ public async Task<int> InstallDependenciesAsync(
6576
6677 int successCount = 0 ;
6778
68- foreach ( var ( name , url , description ) in dependencies )
79+ foreach ( var ( name , url , localFile , description ) in dependencies )
6980 {
7081 cancellationToken . ThrowIfCancellationRequested ( ) ;
7182
@@ -78,15 +89,40 @@ public async Task<int> InstallDependenciesAsync(
7889 try
7990 {
8091 var depSw = Stopwatch . StartNew ( ) ;
81- progress ? . Report ( new InstallationProgress { Message = $ "[DEBUG] Downloading { name } from { url } ", Status = "Debug" } ) ;
82- string sql = await DownloadWithRetryAsync ( url , progress , cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
83- progress ? . Report ( new InstallationProgress { Message = $ "[DEBUG] { name } : downloaded { sql . Length } chars in { depSw . ElapsedMilliseconds } ms", Status = "Debug" } ) ;
92+ string sql ;
93+
94+ /* Check community/ directory first */
95+ string ? localPath = ResolveLocalFile ( localFile ) ;
96+ if ( localPath != null )
97+ {
98+ progress ? . Report ( new InstallationProgress
99+ {
100+ Message = $ "[DEBUG] { name } : loading from { localPath } ",
101+ Status = "Debug"
102+ } ) ;
103+ sql = await File . ReadAllTextAsync ( localPath , cancellationToken ) . ConfigureAwait ( false ) ;
104+ }
105+ else
106+ {
107+ progress ? . Report ( new InstallationProgress
108+ {
109+ Message = $ "[DEBUG] Downloading { name } from { url } ",
110+ Status = "Debug"
111+ } ) ;
112+ sql = await DownloadWithRetryAsync ( url , progress , cancellationToken : cancellationToken ) . ConfigureAwait ( false ) ;
113+ }
114+
115+ progress ? . Report ( new InstallationProgress
116+ {
117+ Message = $ "[DEBUG] { name } : { ( localPath != null ? "loaded" : "downloaded" ) } { sql . Length } chars in { depSw . ElapsedMilliseconds } ms",
118+ Status = "Debug"
119+ } ) ;
84120
85121 if ( string . IsNullOrWhiteSpace ( sql ) )
86122 {
87123 progress ? . Report ( new InstallationProgress
88124 {
89- Message = $ "{ name } - FAILED (empty response)",
125+ Message = $ "{ name } - FAILED (empty { ( localPath != null ? "file" : " response" ) } )",
90126 Status = "Error"
91127 } ) ;
92128 continue ;
@@ -115,9 +151,10 @@ public async Task<int> InstallDependenciesAsync(
115151 await command . ExecuteNonQueryAsync ( cancellationToken ) . ConfigureAwait ( false ) ;
116152 }
117153
154+ string source = localPath != null ? "local" : "GitHub" ;
118155 progress ? . Report ( new InstallationProgress
119156 {
120- Message = $ "{ name } - Success ({ description } )",
157+ Message = $ "{ name } - Success ({ description } ) [ { source } ] ",
121158 Status = "Success"
122159 } ) ;
123160
@@ -158,6 +195,19 @@ public async Task<int> InstallDependenciesAsync(
158195 return successCount ;
159196 }
160197
198+ /// <summary>
199+ /// Checks the community directory for a local copy of the dependency file.
200+ /// Returns the full path if found, null otherwise.
201+ /// </summary>
202+ private string ? ResolveLocalFile ( string fileName )
203+ {
204+ if ( string . IsNullOrEmpty ( _communityDirectory ) || ! Directory . Exists ( _communityDirectory ) )
205+ return null ;
206+
207+ string path = Path . Combine ( _communityDirectory , fileName ) ;
208+ return File . Exists ( path ) ? path : null ;
209+ }
210+
161211 private async Task < string > DownloadWithRetryAsync (
162212 string url ,
163213 IProgress < InstallationProgress > ? progress = null ,
0 commit comments