import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, cpSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { homedir } from 'node:os'; import { createHash } from 'node:crypto'; import { execFileSync, spawnSync } from 'node:child_process'; import { inspectMachineLeases } from '../lib/computer-use-lease.mjs'; import { createComputerUseClient } from '../lib/computer-use-client.mjs'; const root=resolve(import.meta.dirname,'../..'), home=homedir(); if(process.platform!=='darwin' || Number(process.versions.node.split('.')[0])<22) throw new Error('macOS and Node 22+ required'); const manifest=JSON.parse(readFileSync(join(root,'release.json'),'utf8')); for(const [path,expected] of Object.entries(manifest.sha256)) { const actual=createHash('sha256').update(readFileSync(join(root,path))).digest('hex'); if(actual!==expected) throw new Error('Release hash mismatch: '+path); } if(inspectMachineLeases().length) throw new Error('Computer-use input lease exists; wait for its owner before upgrading'); const nativeBinary=join(home,'Applications/SlabMenubar.app/Contents/MacOS/slab-menubar'); const nativeUUID=execFileSync('/usr/bin/dwarfdump',['--uuid',nativeBinary],{encoding:'utf8'}).split(/\s+/)[1]; if(nativeUUID!==manifest.nativeUUID)throw new Error('Install the matching tested native binary before this runtime'); const stamp=new Date().toISOString().replace(/[:.]/g,'-'); const backup=join(home,'.local/share/slab/computer-use/backups',stamp); mkdirSync(backup,{recursive:true,mode:0o700}); const saved=new Set(); const captutor=[]; const hash=path=>createHash('sha256').update(readFileSync(path)).digest('hex'); function save(path) { if(saved.has(path))return; saved.add(path); if(existsSync(path)){const dest=join(backup,path.slice(home.length));mkdirSync(dirname(dest),{recursive:true});cpSync(path,dest,{verbatimSymlinks:true});} } function write(path,text,mode=0o600) { save(path);mkdirSync(dirname(path),{recursive:true}); const tmp=path+'.computer-use-'+process.pid;writeFileSync(tmp,text,{mode});renameSync(tmp,path); } const shell=s=>"'"+s.replaceAll("'","'\\''")+"'"; const xml=s=>String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); const node=process.env.SLAB_NODE || process.execPath; const envPath=[dirname(node),join(home,'.local/bin'),'/opt/homebrew/bin','/usr/local/bin','/usr/bin','/bin','/usr/sbin','/sbin'].join(':'); const configPath=join(home,'.config/slab/puppet.json'); const config=existsSync(configPath)?JSON.parse(readFileSync(configPath,'utf8')):{machines:{}}; config.machines||={};config.machines.local||={local:true,cdpUrl:'http://127.0.0.1:9222',lazy:true}; if(!config.machines.local.local) throw new Error('Existing local machine entry is not local; inspect configuration'); write(configPath,JSON.stringify(config,null,2)+'\n'); for(const name of ['frame','puppet','computer-use']) { write(join(home,'.local/bin',name),'#!/bin/sh\nexec '+shell(node)+' '+shell(join(root,'slab/bin',name+'.mjs'))+' "$@"\n',0o755); } // Captutor imports this module in-process; direct CLI users still work. const frameModule=join(root,'slab/bin/frame.mjs'); write(join(home,'.local/bin/frame.mjs'), 'export * from '+JSON.stringify('file://'+frameModule)+';\n'+ 'import {fileURLToPath} from "node:url"; import {resolve} from "node:path";\n'+ 'if(process.argv[1] && resolve(process.argv[1])===fileURLToPath(import.meta.url)) {\n'+ ' const {spawnSync}=await import("node:child_process");\n'+ ' process.exitCode=spawnSync(process.execPath,['+JSON.stringify(frameModule)+',...process.argv.slice(2)],{stdio:"inherit"}).status ?? 1;\n}\n'); // Iris workers explicitly use this legacy entry instead of the global config. for(const name of ['frame','puppet']) { const entry=join(home,'.hermes/mcp',name,name+'-mcp.mjs'); if(existsSync(entry))write(entry,'import '+JSON.stringify('file://'+join(root,'slab/bin',name+'-mcp.mjs'))+';\n',0o755); } // Update only the known pre-optimization Captutor file, preserving other work. for(const dir of [join(home,'aesthetic-computer/captutor'),join(home,'Developer/captutor')]) { const file=join(dir,'captutor.mjs'); if(!existsSync(file))continue; const current=hash(file), expected=manifest.sha256['captutor/captutor.mjs']; if(current!==expected && current!==manifest.captutorPreviousHash) { captutor.push({path:dir,status:'custom version preserved'});continue; } const client=join(dir,'lib/frame-client.mjs'); if(existsSync(client) && hash(client)!==manifest.sha256['captutor/lib/frame-client.mjs']) { throw new Error('Custom Captutor Frame client requires review: '+client); } if(current!==expected)write(file,readFileSync(join(root,'captutor/captutor.mjs'),'utf8'),0o755); write(client,readFileSync(join(root,'captutor/lib/frame-client.mjs'),'utf8'),0o644); captutor.push({path:dir,status:'updated'}); } const servers=[['puppet-core','slab/bin/puppet.mjs',['daemon']],['frame-mcp','slab/bin/frame-mcp.mjs',['--http','7767']],['puppet-mcp','slab/bin/puppet-mcp.mjs',['--http','7769']]]; const domain='gui/'+process.getuid(); for(const [name,script,args] of servers) { const label='computer.aesthetic.'+name; const plist=join(home,'Library/LaunchAgents',label+'.plist'); const strings=[node,join(root,script),...args].map(s=>''+xml(s)+'').join(''); const content='\n\nLabel'+label+'ProgramArguments'+strings+'EnvironmentVariablesHOME'+xml(home)+'PATH'+xml(envPath)+'RunAtLoadKeepAliveThrottleInterval5StandardOutPath/tmp/'+label+'.outStandardErrorPath/tmp/'+label+'.err\n'; write(plist,content);execFileSync('/usr/bin/plutil',['-lint',plist],{stdio:'pipe'}); spawnSync('/bin/launchctl',['bootout',domain+'/'+label],{stdio:'ignore'}); let started=false; for(let i=0;i<20;i++) { if(spawnSync('/bin/launchctl',['bootstrap',domain,plist],{stdio:'ignore'}).status===0){started=true;break;} await new Promise(r=>setTimeout(r,200)); } if(!started)throw new Error('Could not start '+label); } for(let i=0;i<30;i++) { try{await createComputerUseClient({timeoutMs:1500}).discover();break;} catch(error){if(i===29)throw error;await new Promise(r=>setTimeout(r,300));} } const endpoints={frame:'http://127.0.0.1:7767/mcp',puppet:'http://127.0.0.1:7769/mcp'}; const claudePath=join(home,'.claude.json'); const claude=existsSync(claudePath)?JSON.parse(readFileSync(claudePath,'utf8')):{}; claude.mcpServers||={}; for(const [name,url] of Object.entries(endpoints)) { const entry={type:'http',url};claude.mcpServers[name]=entry; for(const project of Object.values(claude.projects||{}))if(project.mcpServers?.[name])project.mcpServers[name]=entry; } write(claudePath,JSON.stringify(claude,null,2)+'\n'); const codexPath=join(process.env.CODEX_HOME||join(home,'.codex'),'config.toml'); const codex=existsSync(codexPath)?readFileSync(codexPath,'utf8'):''; const preserved=codex.split(/(?=^\[)/m).filter(section=>!/^\[mcp_servers\.(frame|puppet)(?:\.[^\]]+)?\]/.test(section)).join('').trimEnd(); write(codexPath,preserved+'\n\n'+Object.entries(endpoints).map(([name,url])=>'[mcp_servers.'+name+']\nurl = '+JSON.stringify(url)+'\n').join('\n')); write(join(home,'.local/share/slab/computer-use/installed.json'),JSON.stringify({revision:manifest.revision,runtime:root,installedAt:new Date().toISOString(),backup,node,nativeUUID:manifest.nativeUUID,nativeSources:manifest.nativeSources,captutor},null,2)+'\n'); console.log(JSON.stringify({installed:true,revision:manifest.revision,runtime:root,backup,clients:['Claude','Codex'],captutor,services:servers.map(s=>s[0])}));