@@ -3,11 +3,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
33import { assert , describe , it } from "@effect/vitest" ;
44import * as HostProcess from "@t3tools/shared/HostProcess" ;
55import * as Effect from "effect/Effect" ;
6+ import * as Deferred from "effect/Deferred" ;
7+ import * as FileSystem from "effect/FileSystem" ;
68import * as Layer from "effect/Layer" ;
79import * as Logger from "effect/Logger" ;
810import * as PlatformError from "effect/PlatformError" ;
911import * as Sink from "effect/Sink" ;
1012import * as Stream from "effect/Stream" ;
13+ import * as Schema from "effect/Schema" ;
14+ import * as Option from "effect/Option" ;
1115import * as ChildProcess from "effect/process/ChildProcess" ;
1216import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner" ;
1317import * as NodeOS from "node:os" ;
@@ -19,6 +23,15 @@ import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
1923vi . mock ( "node:os" , { spy : true } ) ;
2024
2125const textEncoder = new TextEncoder ( ) ;
26+ const cacheCodec = Schema . fromJsonString (
27+ Schema . Struct ( {
28+ version : Schema . Number ,
29+ key : Schema . String ,
30+ environment : Schema . Record ( Schema . String , Schema . NullOr ( Schema . String ) ) ,
31+ } ) ,
32+ ) ;
33+ const encodeCache = Schema . encodeSync ( cacheCodec ) ;
34+ const decodeCache = Schema . decodeUnknownEffect ( cacheCodec ) ;
2235
2336function envOutput ( values : Readonly < Record < string , string > > ) : string {
2437 return Object . entries ( values )
@@ -69,18 +82,28 @@ function runShellEnvironment(input: {
6982 readonly env : NodeJS . ProcessEnv ;
7083 readonly platform : NodeJS . Platform ;
7184 readonly userShell ?: string ;
85+ readonly stateDir ?: string ;
86+ readonly use ?: (
87+ service : DesktopShellEnvironment . DesktopShellEnvironment [ "Service" ] ,
88+ ) => Effect . Effect < boolean , never , import ( "effect/Scope" ) . Scope > ;
7289 readonly handler : (
7390 command : ChildProcess . Command ,
7491 ) => string | { output : string ; exitCode : number } ;
7592 readonly failure ?: PlatformError . PlatformError ;
7693 readonly processSpawner ?: ChildProcessSpawner . ChildProcessSpawner [ "Service" ] ;
7794} ) {
78- const layerEnvironment = Layer . succeed (
95+ const layerEnvironment = Layer . effect (
7996 DesktopEnvironment . DesktopEnvironment ,
80- DesktopEnvironment . DesktopEnvironment . of ( {
81- platform : input . platform ,
82- } as DesktopEnvironment . DesktopEnvironment [ "Service" ] ) ,
83- ) ;
97+ Effect . gen ( function * ( ) {
98+ const fs = yield * FileSystem . FileSystem ;
99+ const stateDir = input . stateDir ?? ( yield * fs . makeTempDirectoryScoped ( ) ) ;
100+ return DesktopEnvironment . DesktopEnvironment . of ( {
101+ platform : input . platform ,
102+ stateDir,
103+ homeDirectory : "/test-home" ,
104+ } as DesktopEnvironment . DesktopEnvironment [ "Service" ] ) ;
105+ } ) ,
106+ ) . pipe ( Layer . provide ( NodeServices . layer ) ) ;
84107 const layerSpawner = Layer . succeed (
85108 ChildProcessSpawner . ChildProcessSpawner ,
86109 ChildProcessSpawner . make ( ( command ) => {
@@ -97,8 +120,9 @@ function runShellEnvironment(input: {
97120
98121 const program = Effect . gen ( function * ( ) {
99122 const shellEnvironment = yield * DesktopShellEnvironment . DesktopShellEnvironment ;
100- const prepared = yield * shellEnvironment . installIntoProcess ;
101- assert . equal ( yield * shellEnvironment . installIntoProcess , prepared ) ;
123+ if ( input . use ) return yield * input . use ( shellEnvironment ) ;
124+ const prepared = yield * shellEnvironment . awaitDiscovery ;
125+ assert . equal ( yield * shellEnvironment . awaitDiscovery , prepared ) ;
102126 return prepared ;
103127 } ) . pipe (
104128 Effect . provide (
@@ -121,6 +145,247 @@ function runShellEnvironment(input: {
121145}
122146
123147describe ( "DesktopShellEnvironment" , ( ) => {
148+ it . effect ( "does not wait for discovery when the cache is missing" , ( ) =>
149+ Effect . gen ( function * ( ) {
150+ const started = yield * Deferred . make < void > ( ) ;
151+ const release = yield * Deferred . make < void > ( ) ;
152+ yield * runShellEnvironment ( {
153+ env : { SHELL : "/test-shell" , PATH : "/usr/bin" } ,
154+ platform : "darwin" ,
155+ handler : ( ) => "" ,
156+ processSpawner : ChildProcessSpawner . make ( ( ) =>
157+ Deferred . succeed ( started , undefined ) . pipe (
158+ Effect . andThen ( Deferred . await ( release ) ) ,
159+ Effect . as ( makeProcess ( envOutput ( { PATH : "/fresh/bin" } ) ) ) ,
160+ ) ,
161+ ) ,
162+ use : ( service ) =>
163+ Effect . gen ( function * ( ) {
164+ const startup = yield * service . installIntoProcess . pipe ( Effect . forkChild ) ;
165+ yield * Deferred . await ( started ) ;
166+ assert . isDefined ( startup . pollUnsafe ( ) ) ;
167+ yield * Deferred . succeed ( release , undefined ) ;
168+ return yield * service . awaitDiscovery ;
169+ } ) ,
170+ } ) ;
171+ } ) ,
172+ ) ;
173+
174+ it . effect (
175+ "reuses cached paths immediately and streams fresh discovery without keeping removed paths" ,
176+ ( ) =>
177+ Effect . gen ( function * ( ) {
178+ const fs = yield * FileSystem . FileSystem ;
179+ const stateDir = yield * fs . makeTempDirectoryScoped ( ) ;
180+ const spawner = yield * ChildProcessSpawner . ChildProcessSpawner . pipe (
181+ Effect . provide ( NodeServices . layer ) ,
182+ ) ;
183+ const executable = yield * HostProcess . ExecutablePath ;
184+ const first = { SHELL : "/bin/zsh" , PATH : "/usr/bin" } ;
185+ yield * runShellEnvironment ( {
186+ env : first ,
187+ stateDir,
188+ platform : "darwin" ,
189+ handler : ( ) =>
190+ envOutput ( {
191+ PATH : "/old/bin:/usr/bin" ,
192+ __MISE_DIFF : "old-mise" ,
193+ SSH_AUTH_SOCK : "/old-session.sock" ,
194+ JAVA_HOME : "/old/java" ,
195+ SSL_CERT_FILE : "/old/cert.pem" ,
196+ } ) ,
197+ } ) ;
198+ const cachePath = `${ stateDir } /shell-environment-cache.json` ;
199+ const cached = yield * fs . readFileString ( cachePath ) . pipe ( Effect . flatMap ( decodeCache ) ) ;
200+ assert . equal ( cached . environment . PATH , "/old/bin:/usr/bin" ) ;
201+ assert . notProperty ( cached . environment , "SSH_AUTH_SOCK" ) ;
202+ const started = yield * Deferred . make < void > ( ) ;
203+ const release = yield * Deferred . make < void > ( ) ;
204+ const env : NodeJS . ProcessEnv = {
205+ SHELL : "/bin/zsh" ,
206+ PATH : "/launcher/bin" ,
207+ SSH_AUTH_SOCK : "/current-session.sock" ,
208+ } ;
209+ const source = [
210+ "const values = { PATH: '/fresh/bin:' + process.env.PATH, __MISE_DIFF: 'fresh-mise', JAVA_HOME: process.env.JAVA_HOME, SSL_CERT_FILE: process.env.SSL_CERT_FILE };" ,
211+ "for (const [name, value] of Object.entries(values)) {" ,
212+ " if (value !== undefined) process.stdout.write('__T3CODE_ENV_' + name + '_START__\\n' + value + '\\n__T3CODE_ENV_' + name + '_END__\\n');" ,
213+ "}" ,
214+ ] . join ( "\n" ) ;
215+ let calls = 0 ;
216+ yield * runShellEnvironment ( {
217+ env,
218+ stateDir,
219+ platform : "darwin" ,
220+ handler : ( ) => "" ,
221+ processSpawner : ChildProcessSpawner . make ( ( command ) => {
222+ calls ++ ;
223+ return Deferred . succeed ( started , undefined ) . pipe (
224+ Effect . andThen ( Deferred . await ( release ) ) ,
225+ Effect . andThen (
226+ command . _tag === "StandardCommand"
227+ ? spawner . spawn ( ChildProcess . make ( executable , [ "-e" , source ] , command . options ) )
228+ : Effect . succeed ( makeProcess ( "" ) ) ,
229+ ) ,
230+ ) ;
231+ } ) ,
232+ use : ( service ) =>
233+ Effect . gen ( function * ( ) {
234+ yield * service . installIntoProcess ;
235+ yield * service . installIntoProcess ;
236+ assert . equal ( env . PATH , "/old/bin:/usr/bin:/launcher/bin" ) ;
237+ assert . equal ( env . SSH_AUTH_SOCK , "/current-session.sock" ) ;
238+ const replay = yield * Stream . runHead ( service . updates ) ;
239+ assert . isTrue ( Option . isSome ( replay ) ) ;
240+ if ( Option . isSome ( replay ) )
241+ assert . equal ( replay . value . environment . __MISE_DIFF , "old-mise" ) ;
242+ yield * Deferred . await ( started ) ;
243+ assert . equal ( calls , 1 ) ;
244+ yield * Deferred . succeed ( release , undefined ) ;
245+ yield * service . awaitDiscovery ;
246+ assert . equal ( env . PATH , "/fresh/bin:/launcher/bin" ) ;
247+ assert . equal ( env . __MISE_DIFF , "fresh-mise" ) ;
248+ assert . isUndefined ( env . JAVA_HOME ) ;
249+ assert . isUndefined ( env . SSL_CERT_FILE ) ;
250+ const fresh = yield * Stream . runHead ( service . updates ) ;
251+ assert . isTrue ( Option . isSome ( fresh ) ) ;
252+ if ( Option . isSome ( fresh ) ) {
253+ assert . equal ( fresh . value . environment . PATH , env . PATH ) ;
254+ assert . isTrue ( fresh . value . discovered ) ;
255+ }
256+ return true ;
257+ } ) ,
258+ } ) ;
259+ const refreshed = yield * fs . readFileString ( cachePath ) . pipe ( Effect . flatMap ( decodeCache ) ) ;
260+ assert . equal ( refreshed . environment . PATH , "/fresh/bin:/launcher/bin" ) ;
261+ assert . notProperty ( refreshed . environment , "JAVA_HOME" ) ;
262+ assert . notProperty ( refreshed . environment , "SSL_CERT_FILE" ) ;
263+ } ) . pipe ( Effect . provide ( NodeServices . layer ) ) ,
264+ ) ;
265+
266+ it . effect . each ( [
267+ "not json" ,
268+ '{"version":2,"key":"old","environment":{"PATH":"/bad/bin"}}' ,
269+ '{"version":1,"key":"other-shell","environment":{"PATH":"/bad/bin"}}' ,
270+ ] ) ( "ignores an unusable cache and still starts before discovery: %s" , ( contents ) =>
271+ Effect . gen ( function * ( ) {
272+ const fs = yield * FileSystem . FileSystem ;
273+ const stateDir = yield * fs . makeTempDirectoryScoped ( ) ;
274+ yield * fs . writeFileString ( `${ stateDir } /shell-environment-cache.json` , contents ) ;
275+ const started = yield * Deferred . make < void > ( ) ;
276+ const release = yield * Deferred . make < void > ( ) ;
277+ const env : NodeJS . ProcessEnv = { SHELL : "/bin/zsh" , PATH : "/usr/bin" } ;
278+ yield * runShellEnvironment ( {
279+ env,
280+ stateDir,
281+ platform : "darwin" ,
282+ handler : ( ) => "" ,
283+ processSpawner : ChildProcessSpawner . make ( ( ) =>
284+ Deferred . succeed ( started , undefined ) . pipe (
285+ Effect . andThen ( Deferred . await ( release ) ) ,
286+ Effect . as ( makeProcess ( envOutput ( { PATH : "/fresh/bin" } ) ) ) ,
287+ ) ,
288+ ) ,
289+ use : ( service ) =>
290+ Effect . gen ( function * ( ) {
291+ const startup = yield * service . installIntoProcess . pipe ( Effect . forkChild ) ;
292+ yield * Deferred . await ( started ) ;
293+ assert . isDefined ( startup . pollUnsafe ( ) ) ;
294+ assert . equal ( env . PATH , "/usr/bin" ) ;
295+ yield * Deferred . succeed ( release , undefined ) ;
296+ return yield * service . awaitDiscovery ;
297+ } ) ,
298+ } ) ;
299+ assert . equal ( env . PATH , "/fresh/bin:/usr/bin" ) ;
300+ } ) . pipe ( Effect . provide ( NodeServices . layer ) ) ,
301+ ) ;
302+
303+ it . effect (
304+ "retains the last successful cache on refresh failure and ignores session values in the cache" ,
305+ ( ) =>
306+ Effect . gen ( function * ( ) {
307+ const fs = yield * FileSystem . FileSystem ;
308+ const stateDir = yield * fs . makeTempDirectoryScoped ( ) ;
309+ const contents = encodeCache ( {
310+ version : 1 ,
311+ key : '["darwin","/test-home","/bin/zsh",""]' ,
312+ environment : {
313+ PATH : "/cached/bin" ,
314+ __MISE_DIFF : "cached-mise" ,
315+ JAVA_HOME : "/cached/java" ,
316+ NODE_EXTRA_CA_CERTS : "/cached/ca.pem" ,
317+ SSL_CERT_FILE : "/cached/cert.pem" ,
318+ SSL_CERT_DIR : "/cached/certs" ,
319+ SSH_AUTH_SOCK : "/stale.sock" ,
320+ NODE_OPTIONS : "--bad" ,
321+ } ,
322+ } ) ;
323+ const cachePath = `${ stateDir } /shell-environment-cache.json` ;
324+ yield * fs . writeFileString ( cachePath , contents ) ;
325+ const env : NodeJS . ProcessEnv = {
326+ SHELL : "/bin/zsh" ,
327+ PATH : "/usr/bin" ,
328+ __MISE_DIFF : "launcher-mise" ,
329+ JAVA_HOME : "/launcher/java" ,
330+ NODE_EXTRA_CA_CERTS : "/launcher/ca.pem" ,
331+ SSL_CERT_FILE : "" ,
332+ SSH_AUTH_SOCK : "/current.sock" ,
333+ } ;
334+ const prepared = yield * runShellEnvironment ( {
335+ env,
336+ stateDir,
337+ platform : "darwin" ,
338+ handler : ( ) => "" ,
339+ use : ( service ) =>
340+ Effect . gen ( function * ( ) {
341+ yield * service . installIntoProcess ;
342+ const replay = yield * Stream . runHead ( service . updates ) ;
343+ assert . isTrue ( Option . isSome ( replay ) ) ;
344+ if ( Option . isSome ( replay ) ) {
345+ assert . equal ( replay . value . environment . JAVA_HOME , "/launcher/java" ) ;
346+ assert . equal ( replay . value . environment . NODE_EXTRA_CA_CERTS , "/launcher/ca.pem" ) ;
347+ }
348+ return yield * service . awaitDiscovery ;
349+ } ) ,
350+ } ) ;
351+ assert . isFalse ( prepared ) ;
352+ assert . equal ( env . PATH , "/cached/bin:/usr/bin" ) ;
353+ assert . equal ( env . __MISE_DIFF , "cached-mise" ) ;
354+ assert . equal ( env . JAVA_HOME , "/launcher/java" ) ;
355+ assert . equal ( env . NODE_EXTRA_CA_CERTS , "/launcher/ca.pem" ) ;
356+ assert . equal ( env . SSL_CERT_FILE , "" ) ;
357+ assert . equal ( env . SSL_CERT_DIR , "/cached/certs" ) ;
358+ assert . equal ( env . SSH_AUTH_SOCK , "/current.sock" ) ;
359+ assert . isUndefined ( env . NODE_OPTIONS ) ;
360+ assert . equal ( yield * fs . readFileString ( cachePath ) , contents ) ;
361+ } ) . pipe ( Effect . provide ( NodeServices . layer ) ) ,
362+ ) ;
363+
364+ it . effect ( "cancels background discovery when the application scope closes" , ( ) =>
365+ Effect . gen ( function * ( ) {
366+ const started = yield * Deferred . make < void > ( ) ;
367+ const stopped = yield * Deferred . make < void > ( ) ;
368+ yield * runShellEnvironment ( {
369+ env : { SHELL : "/bin/zsh" , PATH : "/usr/bin" } ,
370+ platform : "darwin" ,
371+ handler : ( ) => "" ,
372+ processSpawner : ChildProcessSpawner . make ( ( ) =>
373+ Deferred . succeed ( started , undefined ) . pipe (
374+ Effect . andThen ( Effect . never ) ,
375+ Effect . ensuring ( Deferred . succeed ( stopped , undefined ) ) ,
376+ ) ,
377+ ) ,
378+ use : ( service ) =>
379+ Effect . gen ( function * ( ) {
380+ yield * service . installIntoProcess ;
381+ yield * Deferred . await ( started ) ;
382+ return true ;
383+ } ) ,
384+ } ) ;
385+ yield * Deferred . await ( stopped ) ;
386+ } ) ,
387+ ) ;
388+
124389 it . effect (
125390 "captures the login-shell PATH when startup writes more than a pipe buffer to stderr" ,
126391 ( ) =>
0 commit comments