@@ -8,6 +8,16 @@ type EnvEntry = {
88 readonly value : string
99}
1010
11+ export type InvalidComposeEnvLine = {
12+ readonly lineNumber : number
13+ readonly content : string
14+ }
15+
16+ export type ComposeEnvInspection = {
17+ readonly sanitized : string
18+ readonly invalidLines : ReadonlyArray < InvalidComposeEnvLine >
19+ }
20+
1121const splitLines = ( input : string ) : ReadonlyArray < string > =>
1222 input . replaceAll ( "\r\n" , "\n" ) . replaceAll ( "\r" , "\n" ) . split ( "\n" )
1323
@@ -70,6 +80,19 @@ const parseEnvLine = (line: string): EnvEntry | null => {
7080 return { key, value }
7181}
7282
83+ const inspectComposeEnvLine = ( line : string ) : string | null => {
84+ const trimmed = line . trim ( )
85+ if ( trimmed . length === 0 ) {
86+ return ""
87+ }
88+ if ( trimmed . startsWith ( "#" ) ) {
89+ return trimmed
90+ }
91+
92+ const parsed = parseEnvLine ( line )
93+ return parsed ? `${ parsed . key } =${ parsed . value } ` : null
94+ }
95+
7396// CHANGE: parse env file contents into key/value entries
7497// WHY: allow updating shared auth env deterministically
7598// QUOTE(ТЗ): "система авторизации"
@@ -151,6 +174,73 @@ export const upsertEnvKey = (input: string, key: string, value: string): string
151174// COMPLEXITY: O(n) where n = |lines|
152175export const removeEnvKey = ( input : string , key : string ) : string => upsertEnvKey ( input , key , "" )
153176
177+ // CHANGE: inspect compose env text and canonicalize supported assignments
178+ // WHY: docker compose env_file rejects merge markers and shell-only syntax
179+ // QUOTE(ТЗ): n/a
180+ // REF: user-request-2026-02-26-invalid-project-env
181+ // SOURCE: n/a
182+ // FORMAT THEOREM: ∀l ∈ lines(input): valid_env(l) ∨ comment(l) ∨ empty(l) → l ∈ sanitized(input)
183+ // PURITY: CORE
184+ // INVARIANT: invalid non-comment lines are removed and reported with 1-based line numbers
185+ // COMPLEXITY: O(n) where n = |lines|
186+ export const inspectComposeEnvText = ( input : string ) : ComposeEnvInspection => {
187+ const sanitizedLines : Array < string > = [ ]
188+ const invalidLines : Array < InvalidComposeEnvLine > = [ ]
189+ const lines = splitLines ( input )
190+
191+ for ( const [ index , line ] of lines . entries ( ) ) {
192+ const sanitizedLine = inspectComposeEnvLine ( line )
193+
194+ if ( sanitizedLine === null ) {
195+ invalidLines . push ( {
196+ lineNumber : index + 1 ,
197+ content : line
198+ } )
199+ continue
200+ }
201+
202+ sanitizedLines . push ( sanitizedLine )
203+ }
204+
205+ return {
206+ sanitized : normalizeEnvText ( joinLines ( sanitizedLines ) ) ,
207+ invalidLines
208+ }
209+ }
210+
211+ // CHANGE: sanitize compose env file contents in place
212+ // WHY: make docker compose env_file inputs deterministic and parseable
213+ // QUOTE(ТЗ): n/a
214+ // REF: user-request-2026-02-26-invalid-project-env
215+ // SOURCE: n/a
216+ // FORMAT THEOREM: ∀p: exists_file(p) → compose_safe(read(p)) after sanitize(p)
217+ // PURITY: SHELL
218+ // EFFECT: Effect<ReadonlyArray<InvalidComposeEnvLine>, PlatformError, FileSystem>
219+ // INVARIANT: missing or non-file paths are ignored
220+ // COMPLEXITY: O(n) where n = |file|
221+ export const sanitizeComposeEnvFile = (
222+ fs : FileSystem . FileSystem ,
223+ envPath : string
224+ ) : Effect . Effect < ReadonlyArray < InvalidComposeEnvLine > , PlatformError > =>
225+ Effect . gen ( function * ( _ ) {
226+ const exists = yield * _ ( fs . exists ( envPath ) )
227+ if ( ! exists ) {
228+ return [ ]
229+ }
230+
231+ const info = yield * _ ( fs . stat ( envPath ) )
232+ if ( info . type !== "File" ) {
233+ return [ ]
234+ }
235+
236+ const current = yield * _ ( fs . readFileString ( envPath ) )
237+ const inspected = inspectComposeEnvText ( current )
238+ if ( inspected . sanitized !== normalizeEnvText ( current ) ) {
239+ yield * _ ( fs . writeFileString ( envPath , inspected . sanitized ) )
240+ }
241+ return inspected . invalidLines
242+ } )
243+
154244export const defaultEnvContents = "# docker-git env\n# KEY=value\n"
155245
156246// CHANGE: ensure env file exists
0 commit comments