import fs from 'node:fs/promises'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; export class OpsError extends Error { constructor(code, message, evidence = {}, status = 'BLOCKED') { super(message); Object.assign(this, {code, evidence, status}); } } export function redact(value) { let text=String(value).replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi,'$1[REDACTED]@').replace(/((?:authorization|token|password|secret)\s*[:=]\s*)(?:Bearer\s+)?[^\s,;]+/gi,'$1[REDACTED]'); for(const [key,secret] of Object.entries(process.env)) if(/TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY/i.test(key)&&secret?.length>=8)text=text.split(secret).join('[REDACTED]'); return text; } export function redactObject(value) { if(typeof value==='string')return redact(value); if(Array.isArray(value))return value.map(redactObject); if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([key,item])=>[key,redactObject(item)])); return value; } export function publicResult(value) { const result=redactObject(value); for(const key of ['action','code','summary','root'])if(typeof result[key]==='string')result[key]=result[key].slice(0,key==='summary'?4000:8192); if(Buffer.byteLength(JSON.stringify(result),'utf8')>65536) { result.evidence={truncated:true,reason:'Evidence exceeds 64 KiB JSON limit; inspect local operation logs.'}; if(result.git)result.git={...result.git,changes:result.git.changes?.slice(0,5).map(s=>s.slice(0,1000)),changesTruncated:true}; delete result.sync; } return result; } export const actions = new Set(['status','pull','dev','dev-latest','restart-dev','stop-dev','build','package','doctor']); export function parseArgs(args) { const options = {action: args[0], json: false, timeoutMs: 600000}; if (!actions.has(options.action)) throw new OpsError('INVALID_ACTION', 'Use status, pull, dev, dev-latest, restart-dev, stop-dev, build, package or doctor.'); const seen = new Set(); for (let i=1;i3600000) throw new OpsError('INVALID_TIMEOUT','Timeout must be 1000..3600000 milliseconds.'); options.timeoutMs=Number(value); } else throw new OpsError('INVALID_ARGUMENT', `Unknown option: ${key}`); } return options; } // Never uses a shell. Logs are private local evidence; JSON contains bounded tails only. export async function run(command, args, {cwd, timeoutMs=30000, logDir, env=process.env,maxOutput=65536}={}) { let logPath, handle; if(logDir) { await fs.mkdir(logDir,{recursive:true}); logPath=path.join(logDir,`${Date.now()}-${randomUUID()}.log`); handle=await fs.open(logPath,'wx',0o600); } return new Promise(resolve=>{ let stdout='',stderr='',log='',outputTruncated=false,timedOut=false, finished=false; const child=spawn(command,args,{cwd,env,shell:false,windowsHide:true,stdio:['ignore','pipe','pipe']}); const append=(key,data)=>{ const value=data.toString(); if((key==='stdout'?stdout.length:stderr.length)+value.length>maxOutput)outputTruncated=true; if(key==='stdout') stdout=(stdout+value).slice(-maxOutput); else stderr=(stderr+value).slice(-maxOutput); if(handle) log=(log+value).slice(-8*1024*1024); }; child.stdout.on('data',data=>append('stdout',data)); child.stderr.on('data',data=>append('stderr',data)); const timer=setTimeout(()=>{timedOut=true; child.kill(); setTimeout(()=>{ if(!finished) {child.kill('SIGKILL'); finish(null,'TIMEOUT');} },1500).unref();},timeoutMs); const finish=async(exitCode,error)=>{ if(finished)return; finished=true;clearTimeout(timer);if(timedOut){child.stdout.destroy();child.stderr.destroy();child.unref();}if(handle){await handle.writeFile(redact(log)).catch(()=>{});await handle.close().catch(()=>{});}resolve({exitCode,stdout,stderr:redact(stderr),timedOut,outputTruncated,logPath,error:error?redact(error):undefined}); }; child.on('error',error=>finish(null,error.message));child.on('close',code=>finish(code)); }); } export function requireSuccess(result,code,summary) { if(result.exitCode!==0 || result.timedOut) throw new OpsError(result.timedOut?'TIMEOUT':code,summary,{logPath:result.logPath,exitCode:result.exitCode,stderr:result.stderr.slice(-4000),error:result.error},'FAIL'); return result.stdout.trim(); } export async function withLock(ctx, action, fn) { await fs.mkdir(ctx.stateDir,{recursive:true}); const file=path.join(ctx.stateDir,'workspace.lock'); let handle; try {handle=await fs.open(file,'wx',0o600);} catch(error) {if(error.code!=='EEXIST')throw error; throw new OpsError('WORKSPACE_LOCKED','Another operation owns this workspace. Inspect the lock; never automatically remove it.',{lockPath:file});} const token=randomUUID();await handle.writeFile(JSON.stringify({pid:process.pid,token,action,root:ctx.root,startedAt:new Date().toISOString()}));await handle.close(); try{return await fn();} finally {try {const owner=JSON.parse(await fs.readFile(file,'utf8'));if(owner.token===token && !ctx.keepLock)await fs.unlink(file);}catch{}} } export async function npmInvocation() { if(process.platform!=='win32') return {command:'npm', prefix:[]}; const candidates=[process.env.npm_execpath,path.join(path.dirname(process.execPath),'node_modules/npm/bin/npm-cli.js')].filter(Boolean); for(const file of candidates) if(path.basename(file)==='npm-cli.js') {try{await fs.access(file);return {command:process.execPath,prefix:[file]};}catch{}} throw new OpsError('NPM_NOT_FOUND','Run through npm run ops, or use a Node installation containing npm-cli.js.'); }