@@ -40,7 +40,26 @@ import { copyMissing, detectDevTree } from "./InstallEngine";
4040// Enhancement components are the à-la-carte half of setup. The "LifeOS Core"
4141// (skills + system prompt + base settings + CLAUDE.md) is installed by Setup's
4242// core steps; these are the opt-in extras the user (or their AI) picks some/all/none of.
43- const KNOWN_COMPONENTS = [ "statusline" , "tooltips" , "spinnerverbs" , "agents" , "pulse" , "worksweep" , "derivedsync" ] as const ;
43+ //
44+ // Two kinds:
45+ // - Non-launchd: statusline, tooltips, spinnerverbs, agents, commands — settings.json merges + file copies
46+ // - Launchd services: delegated to Services.ts (single source of truth for all 16 background services)
47+ //
48+ // Component names for launchd services are their short labels (pulse, worksweep, amberroute, etc.)
49+ // or the full label (com.lifeos.pulse). Services.ts handles the mapping.
50+ const NON_LAUNCHD_COMPONENTS = [ "statusline" , "tooltips" , "spinnerverbs" , "agents" , "commands" ] as const ;
51+ type NonLaunchdComponent = ( typeof NON_LAUNCHD_COMPONENTS ) [ number ] ;
52+
53+ // Launchd components — kept in sync with Services.ts. The install for these is delegated to Services.ts.
54+ // Short labels (without com.lifeos. prefix) for convenience; Services.ts accepts both forms.
55+ const LAUNCHD_COMPONENTS = [
56+ "pulse" , "pulse-menubar" , "deriver" , "conduit" , "conduit.insight" , "synthesis" ,
57+ "worksweep" , "derivedsync" , "healthsync" , "codexupdate" , "commitmentsweep" ,
58+ "blogdiscovery" , "usage-aggregator" , "bookmark-watchdog" , "backups" , "amberroute"
59+ ] as const ;
60+ type LaunchdComponent = ( typeof LAUNCHD_COMPONENTS ) [ number ] ;
61+
62+ const KNOWN_COMPONENTS = [ ...NON_LAUNCHD_COMPONENTS , ...LAUNCHD_COMPONENTS ] as const ;
4463type Component = ( typeof KNOWN_COMPONENTS ) [ number ] ;
4564
4665interface Ctx {
@@ -49,6 +68,7 @@ interface Ctx {
4968 payloadRoot : string ; // <skillRoot>/install/LIFEOS — the shipped runtime tree
5069 installRoot : string ; // <skillRoot>/install — settings.enhancements.json + agents/ live here
5170 home : string ;
71+ bun : string ; // resolved bun binary path — substituted for __BUN_PATH__ in launchd plists
5272 launchAgents : string ;
5373 apply : boolean ;
5474}
@@ -124,71 +144,8 @@ function launchctl(args: string[]): { ok: boolean; out: string } {
124144 }
125145}
126146
127- function httpCode ( url : string ) : string {
128- try {
129- return execFileSync ( "curl" , [ "-s" , "-o" , "/dev/null" , "-w" , "%{http_code}" , "--max-time" , "3" , url ] ) . toString ( ) . trim ( ) ;
130- } catch {
131- return "000" ;
132- }
133- }
134-
135147// ── component deployers ──────────────────────────────────────────────
136148
137- /** Pulse: ensure the PULSE tree is laid down, then install + load its plist. */
138- function deployPulse ( ctx : Ctx ) : ComponentResult {
139- const r : ComponentResult = { component : "pulse" , ready : false , actions : [ ] , blockers : [ ] } ;
140- const av = availability ( "PULSE" , ctx ) ;
141- const pulseDir = join ( ctx . lifeosDir , "PULSE" ) ;
142- const plistDst = join ( ctx . launchAgents , "com.lifeos.pulse.plist" ) ;
143-
144- if ( ! av . inLive && ! av . inPayload ) {
145- r . blockers . push ( `PULSE not in live tree (${ pulseDir } ) or payload (${ join ( ctx . payloadRoot , "PULSE" ) } )` ) ;
146- return r ;
147- }
148- r . ready = true ;
149- if ( ! ctx . apply ) {
150- if ( ! av . inLive ) r . actions . push ( `copy PULSE from payload → ${ pulseDir } ` ) ;
151- r . actions . push ( `materialize ${ plistDst } (__HOME__ → ${ ctx . home } )` , "launchctl bootstrap gui/<uid> (skip if already loaded + unchanged)" , "poll 127.0.0.1:31337/healthz until 200" ) ;
152- return r ;
153- }
154-
155- try {
156- ensurePresent ( "PULSE" , ctx ) ;
157- const plistSrc = join ( pulseDir , "com.lifeos.pulse.plist" ) ;
158- if ( ! existsSync ( plistSrc ) ) throw new Error ( `plist template missing at ${ plistSrc } ` ) ;
159- const materialized = readFileSync ( plistSrc , "utf-8" ) . replaceAll ( "__HOME__" , ctx . home ) ;
160- const u = uid ( ) ;
161- const sameOnDisk = existsSync ( plistDst ) && readFileSync ( plistDst , "utf-8" ) === materialized ;
162- const alreadyLoaded = launchctl ( [ "print" , `gui/${ u } /com.lifeos.pulse` ] ) . ok ;
163-
164- // Idempotent: a loaded service with an identical plist is left running.
165- if ( sameOnDisk && alreadyLoaded ) {
166- r . applied = false ;
167- r . probe = { name : "pulse-healthz" , passed : httpCode ( "http://127.0.0.1:31337/healthz" ) === "200" , detail : "already loaded, plist unchanged (idempotent)" } ;
168- return r ;
169- }
170-
171- if ( existsSync ( plistDst ) && ! sameOnDisk ) backup ( plistDst ) ;
172- mkdirSync ( ctx . launchAgents , { recursive : true } ) ;
173- writeFileSync ( plistDst , materialized ) ;
174- if ( alreadyLoaded ) launchctl ( [ "bootout" , `gui/${ u } ` , plistDst ] ) ;
175- const boot = launchctl ( [ "bootstrap" , `gui/${ u } ` , plistDst ] ) ;
176- r . applied = true ;
177-
178- // Readiness poll: launchd bootstrap returns before Pulse binds :31337.
179- let code = "000" ;
180- for ( let i = 0 ; i < 12 ; i ++ ) {
181- code = httpCode ( "http://127.0.0.1:31337/healthz" ) ;
182- if ( code === "200" ) break ;
183- Bun . sleepSync ( 500 ) ;
184- }
185- r . probe = { name : "pulse-healthz" , passed : code === "200" , detail : `healthz → ${ code } ${ boot . ok ? "" : ` (bootstrap: ${ boot . out . trim ( ) } )` } ` } ;
186- } catch ( err ) {
187- r . error = err instanceof Error ? err . message : String ( err ) ;
188- }
189- return r ;
190- }
191-
192149/** Statusline: place the script, chmod +x, wire settings.json statusLine. */
193150function deployStatusline ( ctx : Ctx ) : ComponentResult {
194151 const r : ComponentResult = { component : "statusline" , ready : false , actions : [ ] , blockers : [ ] } ;
@@ -247,44 +204,6 @@ function deployStatusline(ctx: Ctx): ComponentResult {
247204 return r ;
248205}
249206
250- /** Delegate a launchd job to its own standalone installer (no-arg = install). */
251- function deployLaunchdJob ( component : Component , installerRel : string , ctx : Ctx ) : ComponentResult {
252- const r : ComponentResult = { component, ready : false , actions : [ ] , blockers : [ ] } ;
253- const av = availability ( installerRel , ctx ) ;
254- // The installer needs sibling TOOLS files (templates, WorkSweep.ts, …), so the
255- // unit of staging is the whole TOOLS dir — uniform with pulse/statusline.
256- const toolsAv = availability ( "TOOLS" , ctx ) ;
257- const installer = join ( ctx . lifeosDir , installerRel ) ;
258-
259- if ( ! av . inLive && ! toolsAv . inPayload ) {
260- r . blockers . push ( `${ installerRel } not in live tree and TOOLS not in payload (${ join ( ctx . payloadRoot , "TOOLS" ) } ) — runtime tree not staged` ) ;
261- return r ;
262- }
263- r . ready = true ;
264- if ( ! ctx . apply ) {
265- if ( ! av . inLive ) r . actions . push ( `stage TOOLS from payload → ${ join ( ctx . lifeosDir , "TOOLS" ) } ` ) ;
266- r . actions . push ( `bun ${ installer } (delegates plist materialize + launchctl bootstrap)` ) ;
267- return r ;
268- }
269-
270- try {
271- if ( ! existsSync ( installer ) ) ensurePresent ( "TOOLS" , ctx ) ;
272- if ( ! existsSync ( installer ) ) {
273- r . blockers . push ( `installer still missing after staging: ${ installer } ` ) ;
274- return r ;
275- }
276- const out = execFileSync ( "bun" , [ installer ] , { stdio : [ "pipe" , "pipe" , "pipe" ] , timeout : 60000 } ) . toString ( ) ;
277- r . applied = true ;
278- // Confirm the job actually loaded, not just that the installer exited 0.
279- const label = component === "worksweep" ? "com.lifeos.worksweep" : "com.lifeos.derivedsync" ;
280- const loaded = launchctl ( [ "print" , `gui/${ uid ( ) } /${ label } ` ] ) . ok ;
281- r . probe = { name : `${ component } -loaded` , passed : loaded , detail : loaded ? `${ label } loaded` : `installer exit 0 but ${ label } not loaded: ${ out . trim ( ) . split ( "\n" ) . slice ( - 1 ) [ 0 ] } ` } ;
282- } catch ( err ) {
283- r . error = err instanceof Error ? err . message : String ( err ) ;
284- }
285- return r ;
286- }
287-
288207/**
289208 * Merge a single Claude-Code enhancement key (spinnerTipsOverride / spinnerVerbs)
290209 * from the shipped `install/settings.enhancements.json` into the user's
@@ -361,16 +280,105 @@ function deployAgents(ctx: Ctx): ComponentResult {
361280 return r ;
362281}
363282
283+ // commands mirrors agents exactly: copy the payload's install/commands/ into the
284+ // user's ~/.claude/commands/, never overwriting. The payload is already filtered
285+ // at emit time to public commands only (a command ships iff its target skill
286+ // ships), so there is nothing private-pointing to guard against here.
287+ function deployCommands ( ctx : Ctx ) : ComponentResult {
288+ const r : ComponentResult = { component : "commands" , ready : false , actions : [ ] , blockers : [ ] } ;
289+ const src = join ( ctx . installRoot , "commands" ) ;
290+ const dst = join ( ctx . configRoot , "commands" ) ;
291+ if ( ! existsSync ( src ) && ! existsSync ( dst ) ) {
292+ r . blockers . push ( `commands not in payload (${ src } ) and not already installed (${ dst } )` ) ;
293+ return r ;
294+ }
295+ r . ready = true ;
296+ if ( ! ctx . apply ) {
297+ r . actions . push ( existsSync ( src ) ? `copyMissing commands → ${ dst } (never overwrites existing)` : `commands already present at ${ dst } — no-op` ) ;
298+ return r ;
299+ }
300+ try {
301+ if ( ! existsSync ( src ) ) {
302+ r . applied = false ;
303+ r . probe = { name : "commands-present" , passed : true , detail : `already present at ${ dst } (no payload to copy)` } ;
304+ return r ;
305+ }
306+ const { copied, failures } = copyMissing ( src , dst ) ;
307+ r . applied = copied > 0 ;
308+ r . probe = { name : "commands-copied" , passed : failures . length === 0 && existsSync ( dst ) , detail : `${ copied } command file(s) copied${ failures . length ? `, ${ failures . length } failed` : "" } ` } ;
309+ } catch ( err ) {
310+ r . error = err instanceof Error ? err . message : String ( err ) ;
311+ }
312+ return r ;
313+ }
314+
315+ /**
316+ * Delegate launchd service install to Services.ts — single source of truth for all 16 services.
317+ * This keeps DeployComponents focused on non-launchd components (settings merges, file copies)
318+ * while Services.ts owns the full launchd machinery.
319+ */
320+ function deployViaServices ( component : LaunchdComponent , ctx : Ctx ) : ComponentResult {
321+ const r : ComponentResult = { component, ready : false , actions : [ ] , blockers : [ ] } ;
322+ const servicesTs = join ( ctx . lifeosDir , "TOOLS" , "Services.ts" ) ;
323+
324+ // Services.ts must be present (either in live tree or staged from payload)
325+ const av = availability ( "TOOLS" , ctx ) ;
326+ if ( ! av . inLive && ! av . inPayload ) {
327+ r . blockers . push ( `TOOLS not in live tree (${ join ( ctx . lifeosDir , "TOOLS" ) } ) or payload` ) ;
328+ return r ;
329+ }
330+ r . ready = true ;
331+
332+ // Build the label — Services.ts accepts short form (pulse) or full (com.lifeos.pulse)
333+ const label = component . startsWith ( "com.lifeos." ) ? component : `com.lifeos.${ component } ` ;
334+
335+ if ( ! ctx . apply ) {
336+ if ( ! av . inLive ) r . actions . push ( `stage TOOLS from payload → ${ join ( ctx . lifeosDir , "TOOLS" ) } ` ) ;
337+ r . actions . push ( `bun ${ servicesTs . replace ( ctx . home , "~" ) } install --only ${ component } --yes` ) ;
338+ return r ;
339+ }
340+
341+ try {
342+ ensurePresent ( "TOOLS" , ctx ) ;
343+ if ( ! existsSync ( servicesTs ) ) {
344+ r . blockers . push ( `Services.ts still missing after staging: ${ servicesTs } ` ) ;
345+ return r ;
346+ }
347+ // Delegate to Services.ts
348+ const out = execFileSync ( "bun" , [ servicesTs , "install" , "--only" , component , "--yes" ] , {
349+ stdio : [ "pipe" , "pipe" , "pipe" ] ,
350+ timeout : 120000 , // some services take longer (e.g. Pulse waits for healthz)
351+ cwd : dirname ( servicesTs ) ,
352+ } ) . toString ( ) ;
353+ r . applied = true ;
354+ // Confirm the job actually loaded
355+ const loaded = launchctl ( [ "print" , `gui/${ uid ( ) } /${ label } ` ] ) . ok ;
356+ r . probe = { name : `${ component } -loaded` , passed : loaded , detail : loaded ? `${ label } loaded via Services.ts` : `Services.ts exit 0 but ${ label } not loaded: ${ out . trim ( ) . split ( "\n" ) . slice ( - 1 ) [ 0 ] } ` } ;
357+ } catch ( err ) {
358+ r . error = err instanceof Error ? err . message : String ( err ) ;
359+ }
360+ return r ;
361+ }
362+
363+ function isLaunchdComponent ( c : Component ) : c is LaunchdComponent {
364+ return ( LAUNCHD_COMPONENTS as readonly string [ ] ) . includes ( c ) ;
365+ }
366+
364367function deploy ( component : Component , ctx : Ctx ) : ComponentResult {
368+ // Non-launchd components: handled directly
365369 switch ( component ) {
366- case "pulse" : return deployPulse ( ctx ) ;
367370 case "statusline" : return deployStatusline ( ctx ) ;
368371 case "tooltips" : return deploySettingsKey ( "tooltips" , "spinnerTipsOverride" , ctx ) ;
369372 case "spinnerverbs" : return deploySettingsKey ( "spinnerverbs" , "spinnerVerbs" , ctx ) ;
370373 case "agents" : return deployAgents ( ctx ) ;
371- case "worksweep" : return deployLaunchdJob ( "worksweep" , join ( "TOOLS" , "InstallWorkSweep.ts" ) , ctx ) ;
372- case "derivedsync" : return deployLaunchdJob ( "derivedsync" , join ( "TOOLS" , "InstallDerivedSync.ts" ) , ctx ) ;
374+ case "commands" : return deployCommands ( ctx ) ;
375+ }
376+ // Launchd components: delegate to Services.ts
377+ if ( isLaunchdComponent ( component ) ) {
378+ return deployViaServices ( component , ctx ) ;
373379 }
380+ // Fallback (shouldn't reach here with proper types, but TypeScript wants exhaustiveness)
381+ return { component, ready : false , actions : [ ] , blockers : [ `unknown component: ${ component } ` ] } ;
374382}
375383
376384// ── main ─────────────────────────────────────────────────────────────
@@ -417,6 +425,10 @@ function main(): void {
417425 : join ( skillRoot , "install" , "LifeOS" ) ,
418426 installRoot : join ( skillRoot , "install" ) ,
419427 home,
428+ // launchd runs the plist with a minimal PATH, so ProgramArguments[0] must be an
429+ // absolute bun path. Prefer the interpreter running this installer; fall back to
430+ // the standard bun install location.
431+ bun : / \/ b u n $ / . test ( process . execPath ) ? process . execPath : join ( home , ".bun" , "bin" , "bun" ) ,
420432 launchAgents : join ( home , "Library" , "LaunchAgents" ) ,
421433 apply,
422434 } ;
0 commit comments