/** * connection-provenance.ts — WHICH configuration layer named the database. * * The apply policy grades an EF `database update` by the PROVENANCE of * `ConnectionStrings:DefaultConnection`, not only by the topology of its host: * * • `local-override` — `appsettings.Local.json`: gitignored, per developer, seeded * per branch by `ss dev configure` (`SmartStack_`). By construction the * developer's OWN working database, whatever host it sits on. * • `committed` — `appsettings.Development.json` / `appsettings.json`: shared * by the whole team through git — never a sandbox unless its host is provably * local (loopback, this machine, `efcore.localHosts`). * • `environment` — `ConnectionStrings__DefaultConnection` in the process env: a * deployment / CI context. The app (and `dotnet ef`, which inherits this CLI's * environment) reads it ABOVE every appsettings file — so it is the real target. * • `spec` — the caller passed `connectionString` (classification only). * • `none` — nothing resolves. * * Precedence mirrors the generated app, which slots `appsettings.Local.json` just * BELOW the environment-variable provider (`AddSmartStackLocalOverrides`): * command line > environment > appsettings.Local.json > user-secrets > * appsettings.{Environment}.json > appsettings.json * User-secrets and the command line are NOT read here — said out loud in * `notConsulted` rather than silently assumed away. * * File reading reuses the shared `lib/appsettings.ts` resolver (same chain, same * `source` — the one `smartstack-entity-audit` and `uat-plan` already trust). */ import { resolveConnectionString } from '../../../lib/appsettings.js'; export type ConnectionProvenance = 'environment' | 'local-override' | 'committed' | 'spec' | 'none'; export interface ConfigSource { /** Which configuration layer named the database. */ kind: ConnectionProvenance; /** The appsettings file that won (`local-override` / `committed`), else null. */ file: string | null; key: 'ConnectionStrings:DefaultConnection'; /** Layers the generated app reads that this CLI does not — never silently. */ notConsulted: string[]; } export interface ResolvedConnection { value: string | null; source: ConfigSource; warnings: string[]; } export const CONNECTION_KEY = 'ConnectionStrings:DefaultConnection' as const; /** Environment spellings the .NET configuration binder accepts for the same key. */ export const ENV_CONNECTION_KEYS = ['ConnectionStrings__DefaultConnection', 'ConnectionStrings:DefaultConnection'] as const; /** Environment variables that pick `appsettings..json` in the app. */ const ENV_NAME_KEYS = ['ASPNETCORE_ENVIRONMENT', 'DOTNET_ENVIRONMENT'] as const; const NOT_CONSULTED = ['user-secrets', 'command-line'] as const; const LOCAL_OVERRIDE_FILE = 'appsettings.Local.json'; /** Map a resolved appsettings file to its provenance layer. */ export function provenanceOfFile(file: string): 'local-override' | 'committed' { return file.toLowerCase() === LOCAL_OVERRIDE_FILE.toLowerCase() ? 'local-override' : 'committed'; } /** * Case-insensitive env lookup: `process.env` is case-insensitive on Windows but a * plain object (tests, injected env) is not — scan the keys so both behave alike. */ function readEnv(env: NodeJS.ProcessEnv, wanted: readonly string[]): { key: string; value: string } | null { const lowered = new Map(wanted.map((k) => [k.toLowerCase(), k])); for (const [key, value] of Object.entries(env)) { const canonical = lowered.get(key.toLowerCase()); if (canonical && typeof value === 'string' && value.trim()) return { key: canonical, value: value.trim() }; } return null; } function source(kind: ConnectionProvenance, file: string | null): ConfigSource { return { kind, file, key: CONNECTION_KEY, notConsulted: [...NOT_CONSULTED] }; } /** * Resolve the connection string the app would use, and say where it came from. * `apiDir` is the startup project's folder (null → only the environment is read). * Pure over its inputs (filesystem reads aside) — `env` is injected for tests. */ export function resolveConnectionProvenance(apiDir: string | null, env: NodeJS.ProcessEnv = process.env): ResolvedConnection { const warnings: string[] = []; const envName = readEnv(env, ENV_NAME_KEYS); if (envName && envName.value.toLowerCase() !== 'development') { warnings.push( `${envName.key}=${envName.value} is set in this shell — the app (and dotnet ef) would also load appsettings.${envName.value}.json, which this CLI does not read; the classified target may differ from the real one.`, ); } const fromEnv = readEnv(env, ENV_CONNECTION_KEYS); if (fromEnv) { return { value: fromEnv.value, source: source('environment', null), warnings }; } if (!apiDir) { return { value: null, source: source('none', null), warnings }; } const resolved = resolveConnectionString(apiDir); const value = resolved.value?.trim() ?? ''; if (!value || !resolved.source) { return { value: null, source: source('none', null), warnings }; } return { value, source: source(provenanceOfFile(resolved.source), resolved.source), warnings }; }