@@ -26,12 +26,14 @@ export class LaunchdManager {
2626 private readonly plistDir : string ;
2727 private readonly uid : number ;
2828 private readonly domain : string ;
29+ private readonly log : ( message : string ) => void ;
2930
30- constructor ( opts ?: { plistDir ?: string } ) {
31+ constructor ( opts ?: { plistDir ?: string ; log ?: ( message : string ) => void } ) {
3132 this . plistDir =
3233 opts ?. plistDir ?? path . join ( os . homedir ( ) , "Library/LaunchAgents" ) ;
3334 this . uid = os . userInfo ( ) . uid ;
3435 this . domain = `gui/${ this . uid } ` ;
36+ this . log = opts ?. log ?? console . log ;
3537 }
3638
3739 /**
@@ -72,6 +74,16 @@ export class LaunchdManager {
7274
7375 await fs . writeFile ( plistPath , plistContent , "utf8" ) ;
7476
77+ // Clear any persistent "disabled" override left by legacy `launchctl unload -w`.
78+ // Without this, bootstrap fails with error 5 (Input/output error).
79+ const disabled = await this . isServiceDisabled ( label ) ;
80+ if ( disabled ) {
81+ this . log (
82+ `installService: ${ label } has disabled override, clearing with launchctl enable` ,
83+ ) ;
84+ await this . enableService ( label ) ;
85+ }
86+
7587 // Bootstrap with retry: "Input/output error" (code 5) means launchd
7688 // has stale state for this label. Bootout to clear it, then retry.
7789 for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
@@ -95,6 +107,8 @@ export class LaunchdManager {
95107 } catch {
96108 // may already be unregistered
97109 }
110+ // Also clear disabled override in case that's the cause
111+ await this . enableService ( label ) . catch ( ( ) => { } ) ;
98112 await new Promise ( ( r ) => setTimeout ( r , 1000 ) ) ;
99113 continue ;
100114 }
@@ -259,6 +273,45 @@ export class LaunchdManager {
259273 return env ;
260274 }
261275
276+ /**
277+ * Check if a service has a persistent "disabled" override in launchd's database.
278+ * This happens when someone runs `launchctl unload -w` which writes a sticky
279+ * disabled flag, causing all subsequent `launchctl bootstrap` calls to fail
280+ * with error 5 (Input/output error).
281+ */
282+ async isServiceDisabled ( label : string ) : Promise < boolean > {
283+ try {
284+ const { stdout } = await execFileAsync ( "launchctl" , [
285+ "print-disabled" ,
286+ this . domain ,
287+ ] ) ;
288+ // Output format: "io.nexu.controller" => true
289+ const pattern = new RegExp (
290+ `"${ label . replace ( / \. / g, "\\." ) } "\\s*=>\\s*true` ,
291+ ) ;
292+ return pattern . test ( stdout ) ;
293+ } catch {
294+ // Command failed or label not found — assume not disabled
295+ return false ;
296+ }
297+ }
298+
299+ /**
300+ * Clear a persistent "disabled" override for a service.
301+ * Runs `launchctl enable gui/<uid>/<label>` so that subsequent bootstrap
302+ * calls succeed. Tolerates errors (the label may not be in the disabled database).
303+ */
304+ async enableService ( label : string ) : Promise < void > {
305+ try {
306+ await execFileAsync ( "launchctl" , [ "enable" , `${ this . domain } /${ label } ` ] ) ;
307+ this . log ( `enableService: cleared disabled override for ${ label } ` ) ;
308+ } catch ( err ) {
309+ this . log (
310+ `enableService: failed to clear disabled override for ${ label } : ${ err instanceof Error ? err . message : String ( err ) } ` ,
311+ ) ;
312+ }
313+ }
314+
262315 /**
263316 * Check if service is registered with launchd.
264317 */
0 commit comments