Skip to content

Commit cacb0ab

Browse files
authored
Merge pull request #566 from Chris0Jeky/feat/pkg-04-first-run-autoconfig
feat: first-run auto-config (JWT secret, DB path, browser open) (PKG-04 #536)
2 parents 02d49a0 + 1f04680 commit cacb0ab

File tree

9 files changed

+565
-0
lines changed

9 files changed

+565
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ lerna-debug.log*
6262

6363
# Claude Code local settings (personal overrides, not shared)
6464
.claude/settings.local.json
65+
66+
# Auto-generated first-run config (contains JWT secret — never commit)
67+
appsettings.local.json
6568
*.swp
6669
*.swo
6770
*~
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
using System.Security.Cryptography;
2+
using System.Text.Json;
3+
using System.Text.Json.Nodes;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.Configuration.EnvironmentVariables;
6+
7+
namespace Taskdeck.Api.FirstRun;
8+
9+
/// <summary>
10+
/// Runs synchronously before the DI container is built.
11+
/// Ensures that auto-generated configuration values (JWT secret, DB path)
12+
/// are written to <c>appsettings.local.json</c> so they are available to
13+
/// all subsequent configuration consumers.
14+
/// </summary>
15+
public static class FirstRunBootstrapper
16+
{
17+
// Placeholder values that indicate "not configured"
18+
private static readonly HashSet<string> PlaceholderSecrets =
19+
new(StringComparer.OrdinalIgnoreCase)
20+
{
21+
string.Empty,
22+
"TaskdeckDevelopmentOnlySecretKeyChangeMe123!"
23+
};
24+
25+
/// <summary>
26+
/// Registers <c>appsettings.local.json</c> as an optional configuration
27+
/// source so that previously generated secrets are picked up.
28+
/// Call this before building <see cref="WebApplication"/>.
29+
/// </summary>
30+
/// <remarks>
31+
/// The source is inserted <em>before</em> any
32+
/// <see cref="EnvironmentVariablesConfigurationSource"/> entries so that
33+
/// environment variables always win over the auto-generated file. This
34+
/// preserves 12-factor / container deployment patterns where operators
35+
/// supply <c>Jwt__SecretKey</c> (or similar) via environment variables
36+
/// and must not be silently overridden by a previously written file.
37+
/// </remarks>
38+
public static WebApplicationBuilder AddLocalConfigFile(this WebApplicationBuilder builder)
39+
{
40+
var sources = builder.Configuration.Sources;
41+
42+
// Find the first EnvironmentVariablesConfigurationSource so we can
43+
// insert the file source before it, giving env vars higher priority.
44+
var envIndex = -1;
45+
for (var i = 0; i < sources.Count; i++)
46+
{
47+
if (sources[i] is EnvironmentVariablesConfigurationSource)
48+
{
49+
envIndex = i;
50+
break;
51+
}
52+
}
53+
54+
var fileSource = new Microsoft.Extensions.Configuration.Json.JsonConfigurationSource
55+
{
56+
Path = LocalConfigPath,
57+
Optional = true,
58+
ReloadOnChange = false
59+
};
60+
// Resolve the file provider so the source can locate the file.
61+
fileSource.ResolveFileProvider();
62+
63+
if (envIndex >= 0)
64+
{
65+
sources.Insert(envIndex, fileSource);
66+
}
67+
else
68+
{
69+
// No env-var source found (unusual); append at end.
70+
sources.Add(fileSource);
71+
}
72+
73+
return builder;
74+
}
75+
76+
/// <summary>
77+
/// Runs the first-run checks. Must be called after
78+
/// <see cref="AddLocalConfigFile"/> and after the standard
79+
/// <c>appsettings.json</c> / <c>appsettings.{env}.json</c> files have been
80+
/// loaded (i.e. after the builder is constructed).
81+
/// </summary>
82+
/// <remarks>
83+
/// Checks are skipped in Development and in CI/headless environments.
84+
/// They are intended for the packaged self-hosted production scenario.
85+
/// </remarks>
86+
public static WebApplicationBuilder RunFirstRunChecks(
87+
this WebApplicationBuilder builder,
88+
ILogger logger)
89+
{
90+
// First-run checks are for the self-hosted packaged distribution.
91+
// In Development the developer supplies their own config values.
92+
if (builder.Environment.IsDevelopment())
93+
{
94+
return builder;
95+
}
96+
97+
// Also skip in CI / automated environments.
98+
if (IsHeadlessEnvironment())
99+
{
100+
return builder;
101+
}
102+
103+
EnsureJwtSecret(builder.Configuration, logger);
104+
EnsureDbPath(builder.Configuration, logger);
105+
return builder;
106+
}
107+
108+
// -------------------------------------------------------------------------
109+
110+
internal static string LocalConfigPath
111+
{
112+
get
113+
{
114+
var dir = AppContext.BaseDirectory;
115+
return Path.Combine(dir, "appsettings.local.json");
116+
}
117+
}
118+
119+
internal static bool IsPlaceholder(string value)
120+
=> string.IsNullOrWhiteSpace(value) || PlaceholderSecrets.Contains(value.Trim());
121+
122+
internal static string GenerateSecret()
123+
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
124+
125+
private static void EnsureJwtSecret(IConfiguration configuration, ILogger logger)
126+
{
127+
var configured = configuration["Jwt:SecretKey"] ?? string.Empty;
128+
129+
if (!IsPlaceholder(configured))
130+
{
131+
return;
132+
}
133+
134+
var generated = GenerateSecret();
135+
PersistValue("Jwt", "SecretKey", generated);
136+
// Reload so subsequent configuration reads get the new value.
137+
if (configuration is IConfigurationRoot root)
138+
{
139+
root.Reload();
140+
}
141+
142+
logger.LogInformation(
143+
"First-run: JWT secret was not configured. A random secret has been " +
144+
"generated and saved to {ConfigFile}.", LocalConfigPath);
145+
}
146+
147+
private static void EnsureDbPath(IConfiguration configuration, ILogger logger)
148+
{
149+
var resolveAppData = configuration.GetValue<bool?>("FirstRun:ResolveAppDataDbPath") ?? true;
150+
if (!resolveAppData)
151+
{
152+
return;
153+
}
154+
155+
var connectionString = configuration.GetConnectionString("DefaultConnection")
156+
?? string.Empty;
157+
var dataSource = ExtractDataSource(connectionString);
158+
159+
// Already an absolute path — nothing to do.
160+
if (!string.IsNullOrWhiteSpace(dataSource) && Path.IsPathRooted(dataSource))
161+
{
162+
return;
163+
}
164+
165+
var appDataDir = GetAppDataPath();
166+
Directory.CreateDirectory(appDataDir);
167+
168+
var dbFile = string.IsNullOrWhiteSpace(dataSource)
169+
? "taskdeck.db"
170+
: Path.GetFileName(dataSource);
171+
172+
var resolvedPath = Path.Combine(appDataDir, dbFile);
173+
var resolvedConnectionString = $"Data Source={resolvedPath}";
174+
175+
// Write into the local config file so the value is picked up by
176+
// AddInfrastructure later in the startup pipeline.
177+
PersistValue("ConnectionStrings", "DefaultConnection", resolvedConnectionString);
178+
179+
if (configuration is IConfigurationRoot root)
180+
{
181+
root.Reload();
182+
}
183+
184+
logger.LogInformation(
185+
"First-run: SQLite DB path resolved to AppData location: {DbPath}", resolvedPath);
186+
}
187+
188+
internal static bool IsHeadlessEnvironment()
189+
{
190+
return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))
191+
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TF_BUILD"))
192+
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"))
193+
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TASKDECK_HEADLESS"));
194+
}
195+
196+
internal static string GetAppDataPath()
197+
{
198+
var localAppData = Environment.GetFolderPath(
199+
Environment.SpecialFolder.LocalApplicationData,
200+
Environment.SpecialFolderOption.Create);
201+
return Path.Combine(localAppData, "Taskdeck");
202+
}
203+
204+
private static string ExtractDataSource(string connectionString)
205+
{
206+
if (string.IsNullOrWhiteSpace(connectionString))
207+
return string.Empty;
208+
209+
try
210+
{
211+
var builder = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString);
212+
return builder.DataSource;
213+
}
214+
catch (ArgumentException)
215+
{
216+
return string.Empty;
217+
}
218+
}
219+
220+
private static void PersistValue(string section, string key, string value)
221+
{
222+
var path = LocalConfigPath;
223+
224+
JsonObject root;
225+
if (File.Exists(path))
226+
{
227+
try
228+
{
229+
var existing = File.ReadAllText(path);
230+
root = JsonNode.Parse(existing)?.AsObject() ?? new JsonObject();
231+
}
232+
catch (Exception ex)
233+
{
234+
// Logger is not available at this pre-DI stage; warn to stderr and start fresh
235+
// rather than silently discarding the corrupt file.
236+
Console.Error.WriteLine(
237+
$"[FirstRun] WARNING: {path} contains invalid JSON and will be overwritten. " +
238+
$"Details: {ex.Message}");
239+
root = new JsonObject();
240+
}
241+
}
242+
else
243+
{
244+
root = new JsonObject();
245+
}
246+
247+
if (root[section] is not JsonObject sectionNode)
248+
{
249+
sectionNode = new JsonObject();
250+
root[section] = sectionNode;
251+
}
252+
253+
sectionNode[key] = value;
254+
255+
var options = new JsonSerializerOptions { WriteIndented = true };
256+
File.WriteAllText(path, root.ToJsonString(options));
257+
}
258+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.Diagnostics;
2+
3+
namespace Taskdeck.Api.FirstRun;
4+
5+
/// <summary>
6+
/// Runtime first-run service. Handles post-startup concerns:
7+
/// browser auto-open after the host is ready.
8+
/// Configuration bootstrapping (JWT secret, DB path) is handled at build-time
9+
/// by <see cref="FirstRunBootstrapper"/>.
10+
/// </summary>
11+
public sealed class FirstRunService
12+
{
13+
private readonly ILogger<FirstRunService> _logger;
14+
private readonly FirstRunSettings _settings;
15+
16+
public FirstRunService(
17+
ILogger<FirstRunService> logger,
18+
FirstRunSettings settings)
19+
{
20+
_logger = logger;
21+
_settings = settings;
22+
}
23+
24+
/// <summary>
25+
/// Opens the browser to the given URL when
26+
/// <see cref="FirstRunSettings.AutoOpenBrowser"/> is enabled and the
27+
/// process is not running in a headless/CI context.
28+
/// </summary>
29+
/// <param name="url">The URL to open. Typically the actual listening address
30+
/// obtained from <c>IServerAddressesFeature</c>.</param>
31+
public void TryOpenBrowser(string url)
32+
{
33+
if (!_settings.AutoOpenBrowser)
34+
{
35+
return;
36+
}
37+
38+
if (FirstRunBootstrapper.IsHeadlessEnvironment())
39+
{
40+
_logger.LogDebug(
41+
"First-run: AutoOpenBrowser skipped (headless/CI environment detected).");
42+
return;
43+
}
44+
45+
_logger.LogInformation("First-run: Opening browser at {Url}", url);
46+
47+
try
48+
{
49+
Process.Start(new ProcessStartInfo
50+
{
51+
FileName = url,
52+
UseShellExecute = true
53+
});
54+
}
55+
catch (Exception ex)
56+
{
57+
// Non-fatal: the user can always open the browser manually.
58+
_logger.LogWarning(ex, "First-run: Failed to open browser at {Url}.", url);
59+
}
60+
}
61+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace Taskdeck.Api.FirstRun;
2+
3+
/// <summary>
4+
/// Configuration for first-run automatic setup behaviour.
5+
/// </summary>
6+
public sealed class FirstRunSettings
7+
{
8+
/// <summary>
9+
/// When true the app opens the browser to the local URL after startup.
10+
/// Defaults to false so it never fires in CI or server deployments.
11+
/// </summary>
12+
public bool AutoOpenBrowser { get; set; } = false;
13+
14+
/// <summary>
15+
/// Port the API listens on. Used to build the browser URL.
16+
/// </summary>
17+
public int Port { get; set; } = 5000;
18+
19+
/// <summary>
20+
/// When true the first-run service resolves the DB path to the OS
21+
/// AppData/local-share folder if no explicit path is configured.
22+
/// Defaults to true.
23+
/// </summary>
24+
public bool ResolveAppDataDbPath { get; set; } = true;
25+
}

0 commit comments

Comments
 (0)