← back to Cncp Mcp
scripts/apply-pm2-caps.sh
210 lines
#!/usr/bin/env bash
# apply-pm2-caps.sh — generate ecosystem.config.cjs + apply memory caps for any
# pm2 proc that's currently uncapped. Per-project, idempotent, dry-run default.
#
# Why: Steve has 21+ pm2 procs without ecosystem.config files → no
# max_memory_restart → any leaking proc drags Mac2 load to 500+.
#
# Usage:
# bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh # dry-run (default)
# bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh --write # write ecosystem.config.cjs files only
# bash ~/Projects/cncp-mcp/scripts/apply-pm2-caps.sh --cycle # write + delete/restart each proc serially
#
# What it does:
# --write : creates ecosystem.config.cjs in each proc's cwd (only if missing)
# with the proc's current script + name + port env + cap. Doesn't restart.
# --cycle : --write, then for each proc: pm2 delete → pm2 start ecosystem.config.cjs
# with a 2s gap between procs to avoid load spike. pm2 save at end.
#
# Safety:
# - Never overwrites an existing ecosystem.config.{js,cjs}.
# - Default cap is 300M for sister-sites; can override via SISTER_CAP env.
# - Skips procs whose script path can't be resolved.
# - Logs every action to /tmp/pm2-caps-<ts>.log.
set -u
MODE="dry"
for arg in "$@"; do
case "$arg" in
--write) MODE="write" ;;
--cycle) MODE="cycle" ;;
--dry-run) MODE="dry" ;;
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
esac
done
SISTER_CAP="${SISTER_CAP:-300M}"
LLM_CAP="${LLM_CAP:-1G}"
PM2_BIN="${PM2_BIN:-$HOME/.npm-global/bin/pm2}"
[[ -x "$PM2_BIN" ]] || PM2_BIN="$(/usr/bin/which pm2 2>/dev/null || echo pm2)"
TS="$(date +%Y%m%d-%H%M%S)"
LOG="/tmp/pm2-caps-${TS}.log"
log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$LOG"; }
log "mode=$MODE sister-cap=$SISTER_CAP llm-cap=$LLM_CAP"
# pm2 jlist contains ALL env vars including real API keys/secrets the shell had
# at proc-start time. NEVER write it to a long-lived path. Use a temp file and
# trap-unlink on exit so it's nuked even if the script crashes.
JLIST_TMP="$(mktemp -t pm2-jlist.XXXXXX)"
trap 'rm -f "$JLIST_TMP"' EXIT
"$PM2_BIN" jlist 2>/dev/null > "$JLIST_TMP" || {
log "ERROR: pm2 jlist failed"
exit 3
}
CAPS_MODE="$MODE" \
CAPS_SISTER="$SISTER_CAP" \
CAPS_LLM="$LLM_CAP" \
CAPS_LOG="$LOG" \
CAPS_PM2="$PM2_BIN" \
CAPS_JLIST="$JLIST_TMP" \
/usr/bin/python3 <<'PYEOF'
import json, os, sys, subprocess, time
MODE = os.environ["CAPS_MODE"]
SISTER_CAP = os.environ["CAPS_SISTER"]
LLM_CAP = os.environ["CAPS_LLM"]
LOG = os.environ["CAPS_LOG"]
PM2 = os.environ["CAPS_PM2"]
JLIST = os.environ["CAPS_JLIST"]
def logmsg(s):
line = f"[{time.strftime('%H:%M:%S', time.gmtime())}] {s}"
print(line)
with open(LOG, 'a') as f: f.write(line + "\n")
procs = json.load(open(JLIST))
# Identify candidates: no max_memory_restart, has a valid pm_cwd + pm_exec_path
todo = []
for p in procs:
env = p.get('pm2_env', {})
name = p.get('name', '?')
if env.get('max_memory_restart'): continue
pm_cwd = env.get('pm_cwd', '') or ''
pm_exec_path = env.get('pm_exec_path', '') or ''
if not pm_cwd or not pm_exec_path:
logmsg(f"SKIP {name}: missing cwd or script path")
continue
if not os.path.isfile(pm_exec_path):
logmsg(f"SKIP {name}: script path doesn't exist: {pm_exec_path}")
continue
# Try to extract PORT from env
port = env.get('PORT') or env.get('port') or ''
# Decide cap: LLM agents get more, sister-sites less
name_low = name.lower()
is_llm = any(k in name_low for k in ('agent', 'gpt', 'llm', 'codex', 'claude', 'orchestrator', 'factory', 'debrief'))
cap = LLM_CAP if is_llm else SISTER_CAP
todo.append({'name': name, 'cwd': pm_cwd, 'script': pm_exec_path, 'port': port, 'cap': cap})
logmsg(f"candidates: {len(todo)}")
for t in todo:
logmsg(f" {t['name']:<32} cwd={t['cwd']} script={os.path.basename(t['script'])} cap={t['cap']}")
if MODE == "dry":
logmsg("DRY-RUN — no files written, no pm2 changes. Re-run with --write or --cycle.")
sys.exit(0)
# --write : create ecosystem.config.cjs in each cwd if missing
created = 0
already = 0
for t in todo:
eco_cjs = os.path.join(t['cwd'], 'ecosystem.config.cjs')
eco_js = os.path.join(t['cwd'], 'ecosystem.config.js')
if os.path.isfile(eco_cjs) or os.path.isfile(eco_js):
logmsg(f"already-has-ecosystem {t['name']}")
already += 1
continue
# Build the script body — STRICT WHITELIST env approach.
# We do NOT capture arbitrary env vars from pm2_env because that pool
# contains real secrets (API keys, DB passwords) inherited from the shell
# that started pm2. Writing those into ecosystem.config.cjs and committing
# to git is a security leak. Instead: capture ONLY known-safe keys.
proc = [p for p in procs if p.get('name') == t['name']][0]
full_env = proc.get('pm2_env', {})
SAFE_KEYS = {'PORT', 'port', 'NODE_ENV', 'HOST', 'BIND', 'HOSTNAME',
'FLASK_RUN_HOST', 'UVICORN_HOST', 'GUNICORN_BIND', 'VITE_HOST',
'NEXT_TELEMETRY_DISABLED'}
# Any *_URL is allowed only if it doesn't contain '@' (which signals creds in URL)
SECRET_PATTERNS = ('_KEY', '_SECRET', '_TOKEN', '_PASSWORD', '_PASS',
'_AUTH', '_CREDENTIAL', 'API_', 'PRIVATE_', 'COOKIE',
'SESSION_SECRET', 'JWT')
user_env = {}
for k, v in full_env.items():
if not isinstance(v, (str, int, bool)): continue
sv = str(v)
if len(sv) > 500: continue
# Direct allowlist
if k in SAFE_KEYS:
user_env[k] = v
continue
# *_URL allowed only if no embedded creds
if k.endswith('_URL') and '@' not in sv:
# Also exclude obviously-secret URL keys
if any(p in k.upper() for p in SECRET_PATTERNS): continue
user_env[k] = v
continue
# Everything else: SKIP. Secrets, pm2 internals, macOS env, claude-session
# state, etc. all stay out of the ecosystem file.
continue
env_lines = []
for k, v in user_env.items():
if isinstance(v, bool):
env_lines.append(f" {k}: {str(v).lower()},")
elif isinstance(v, int):
env_lines.append(f" {k}: {v},")
else:
esc = str(v).replace("\\", "\\\\").replace('"', '\\"')
env_lines.append(f' {k}: "{esc}",')
env_block = "\n".join(env_lines) + "\n" if env_lines else ""
script_basename = os.path.basename(t['script'])
content = f"""module.exports = {{
apps: [{{
name: "{t['name']}",
script: "{script_basename}",
cwd: __dirname,
env: {{
{env_block} }},
autorestart: true,
max_memory_restart: "{t['cap']}",
}}],
}};
"""
try:
with open(eco_cjs, 'w') as f: f.write(content)
logmsg(f"WROTE {eco_cjs}")
created += 1
except Exception as e:
logmsg(f"WRITE-FAIL {t['name']}: {e}")
logmsg(f"--write done: {created} created, {already} already had one.")
if MODE == "cycle":
logmsg("--cycle: delete + restart each, 2s gap")
for t in todo:
eco = os.path.join(t['cwd'], 'ecosystem.config.cjs')
if not os.path.isfile(eco):
logmsg(f" skip {t['name']}: no ecosystem.config.cjs")
continue
logmsg(f" cycle {t['name']}")
subprocess.run([PM2, "delete", t['name']], capture_output=True)
r = subprocess.run([PM2, "start", eco], capture_output=True, text=True)
if r.returncode != 0:
logmsg(f" START FAIL: {r.stderr[:200]}")
else:
logmsg(f" started")
time.sleep(2)
logmsg("running pm2 save")
subprocess.run([PM2, "save"], capture_output=True)
logmsg("--cycle done.")
PYEOF
echo ""
echo "Log: $LOG"