feat: add deterministic daily ops SOP and verified dev lifecycle

This commit is contained in:
Codex committed 2026-10-21 16:50:11 +09:00
1 parent dac4af53e0
commit 2aafb6f92f
16 files changed
+935 -5

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
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;i<args.length;i++) {
const key=args[i];
if (seen.has(key)) throw new OpsError('INVALID_ARGUMENT', `Duplicate option: ${key}`);
seen.add(key);
if(key==='--json') options.json=true;
else if(key==='--root') {
const value=args[++i];
if(!value || !path.isAbsolute(value) || value.includes('\0')) throw new OpsError('INVALID_ROOT','--root requires an absolute directory.');
options.root=value;
} else if(key==='--timeout-ms') {
const value=args[++i];
if(!/^\d+$/.test(value || '') || Number(value)<1000 || Number(value)>3600000) 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.');
}