@awesomate/hosting-mcp
Version:
Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases
190 lines (173 loc) • 14.7 kB
JavaScript
/**
* Pre-deploy check for a repo that was NOT scaffolded by Awesomate (Replit,
* Lovable, anything). Zero dependencies. Prints a JSON report Claude posts
* to the hub with awesomate_app_repo_check, which stores it, updates the
* migration plan, and may open a support ticket for blockers.
*
* node migrate-check.mjs [<repo-dir>] [--fix]
*
* --fix applies the one repair that is purely mechanical: regenerating a
* lockfile that points at a private registry. Everything else is reported
* with the fix spelled out so Claude can do it in the code.
*
* Exit code is always 0. Severity: blocker (the deploy will fail or must not
* happen), warn (will bite later), info (worth knowing).
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
const args = process.argv.slice(2);
const fix = args.includes('--fix');
const dir = resolve(args.find((a) => !a.startsWith('--')) ?? '.');
const findings = [];
let deploy = null;
const detected = { node: null, packageManager: 'npm', build: null, start: null, scripts: {}, deps: {}, devDeps: {}, ready_path: '/api/ready', hasReadyRoute: false, drizzle: false, drizzleSql: false, migrationsDir: null, usesPortEnv: false, usesDatabaseUrl: false, replit: {}, websockets: false, largeDirs: [], sourceFiles: 0 };
const add = (id, severity, title, extra = {}) => findings.push({ id, severity, title, ...extra });
const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
const pkg = readJson(join(dir, 'package.json'));
if (!pkg) {
add('no_package_json', 'blocker', 'No package.json at the repo root', { fix: 'Point me at the folder that holds package.json, or this is not a Node app.' });
finish('unknown');
}
detected.scripts = pkg.scripts ?? {};
detected.deps = pkg.dependencies ?? {};
detected.devDeps = pkg.devDependencies ?? {};
detected.build = detected.scripts.build ?? null;
detected.start = detected.scripts.start ?? null;
detected.node = pkg.engines?.node ?? null;
// --- platform --------------------------------------------------------------
const hasReplitFiles = existsSync(join(dir, '.replit')) || existsSync(join(dir, 'replit.nix'));
const replitDeps = Object.keys({ ...detected.deps, ...detected.devDeps }).filter((d) => d.startsWith('@replit/'));
const lovable = existsSync(join(dir, 'supabase')) || Object.keys(detected.deps).some((d) => d.startsWith('@supabase/')) && existsSync(join(dir, 'vite.config.ts')) && !detected.deps.express;
const platform = hasReplitFiles || replitDeps.length ? 'replit' : lovable ? 'lovable' : existsSync(join(dir, '.awesomate.json')) ? 'scaffold' : 'unknown';
detected.replit = { files: hasReplitFiles, deps: replitDeps };
// --- source scan -------------------------------------------------------------
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'attached_assets', 'scripts']);
const SRC_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
const files = [];
(function walk(d, depth) {
if (depth > 8) return;
for (const e of readdirSync(d, { withFileTypes: true })) {
if (SKIP.has(e.name)) continue;
const p = join(d, e.name);
if (e.isDirectory()) walk(p, depth + 1);
else if (SRC_RE.test(e.name)) files.push(p);
}
})(dir, 0);
detected.sourceFiles = files.length;
const SECRET_RE = /(sk-[A-Za-z0-9]{16,}|sk_live_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[bp]-[A-Za-z0-9-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(ql)?:\/\/[^:\s]+:[^@\s]+@)/;
let readyFound = false, portEnv = false, portHardcoded = null, dbUrl = false, neon = false, replEnv = [], replitAuth = false, ws = false, evalUse = false;
for (const f of files) {
let text;
try { if (statSync(f).size > 1_000_000) continue; text = readFileSync(f, 'utf8'); } catch { continue; }
const rel = relative(dir, f);
if (SECRET_RE.test(text) && !/\.example\./.test(rel)) add('secret_in_source', 'blocker', 'Something shaped like a secret is committed', { file: rel, fix: 'Rotate it with the provider, remove it from the code, store it with awesomate_app_set_env.' });
if (/['"`]\/api\/ready['"`]/.test(text)) readyFound = true;
if (/process\.env\.PORT/.test(text)) portEnv = true;
const listen = text.match(/\.listen\(\s*(\d{4,5})\s*[,)]/);
if (listen && !/process\.env\.PORT/.test(text)) portHardcoded = { file: rel, port: listen[1] };
if (/process\.env\.DATABASE_URL/.test(text)) dbUrl = true;
if (/@neondatabase\/serverless|drizzle-orm\/neon-/.test(text)) neon = true;
for (const v of ['REPL_ID', 'REPLIT_DOMAINS', 'REPLIT_DEV_DOMAIN', 'REPL_SLUG', 'REPL_OWNER']) if (text.includes(v) && !replEnv.includes(v)) replEnv.push(v);
if (/replit\.com\/oidc|ISSUER_URL.*replit|openid-client/.test(text) && /REPL/.test(text)) replitAuth = true;
if (/from ['"](ws|socket\.io)['"]|require\(['"](ws|socket\.io)['"]\)|new WebSocketServer/.test(text)) ws = true;
if (/\beval\(|new Function\(/.test(text)) evalUse = true;
}
detected.hasReadyRoute = readyFound;
detected.usesPortEnv = portEnv;
detected.usesDatabaseUrl = dbUrl;
detected.websockets = ws;
// --- tracked .env ------------------------------------------------------------
try {
const tracked = execSync('git ls-files .env */.env', { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
if (tracked) add('tracked_env_file', 'blocker', 'A .env file is tracked by git', { file: tracked.split('\n')[0], fix: 'git rm --cached it, add .env to .gitignore, rotate anything in it.' });
} catch { /* not a git repo */ }
// --- lockfile ----------------------------------------------------------------
const lockPath = join(dir, 'package-lock.json');
if (!existsSync(lockPath)) {
const alt = ['yarn.lock', 'pnpm-lock.yaml', 'bun.lockb'].find((f) => existsSync(join(dir, f)));
if (alt) detected.packageManager = alt.split('.')[0].replace('lockb', 'bun');
add('no_npm_lockfile', 'blocker', alt ? `Uses ${alt}; the deploy runs npm ci` : 'No package-lock.json', { fix: 'Run npm install --package-lock-only to create package-lock.json (keep the other lockfile if you like).' });
} else {
const lock = readJson(lockPath);
const hosts = new Set();
const walkPkgs = (pkgs) => { for (const [, v] of Object.entries(pkgs ?? {})) { if (v?.resolved) { try { hosts.add(new URL(v.resolved).host); } catch { /* ignore */ } } } };
walkPkgs(lock?.packages); walkPkgs(lock?.dependencies);
const foreign = [...hosts].filter((h) => h !== 'registry.npmjs.org' && h !== 'github.com' && h !== 'codeload.github.com');
if (foreign.length) {
if (fix) {
try {
execSync('npm install --package-lock-only --ignore-scripts --registry https://registry.npmjs.org', { cwd: dir, stdio: 'pipe' });
add('lockfile_private_registry', 'info', `Lockfile pointed at ${foreign.join(', ')}; regenerated against the public registry`, { fix: 'Commit the new package-lock.json.' });
} catch (err) {
add('lockfile_private_registry', 'blocker', `Lockfile resolves packages from ${foreign.join(', ')} and regeneration failed`, { detail: String(err.message).slice(0, 300), fix: 'rm package-lock.json && npm install --package-lock-only --registry https://registry.npmjs.org' });
}
} else {
add('lockfile_private_registry', 'blocker', `Lockfile resolves packages from ${foreign.join(', ')}, unreachable from Awesomate and from GitHub Actions`, { fix: 'node migrate-check.mjs --fix (regenerates the lockfile against registry.npmjs.org), then commit it.' });
}
}
}
// --- app contract --------------------------------------------------------------
if (!detected.build) add('no_build_script', 'blocker', 'package.json has no "build" script', { fix: 'Add one that produces the artefacts (for example vite build && esbuild server/index.ts --bundle --platform=node --format=esm --outdir=dist).' });
if (!readyFound) add('ready_route_missing', 'blocker', 'No /api/ready route', { fix: 'Add a route that returns 200 only when the database answers (see AWESOMATE.md). The deploy and health checks probe it.' });
if (portHardcoded) add('port_hardcoded', 'blocker', `Server listens on a fixed port ${portHardcoded.port}`, { file: portHardcoded.file, fix: 'Use Number(process.env.PORT); the account assigns one port per environment.' });
else if (!portEnv) add('port_env_unknown', 'warn', 'Could not confirm the server reads process.env.PORT', { fix: 'Make sure app.listen uses process.env.PORT.' });
if (!dbUrl && (detected.deps.pg || detected.deps['drizzle-orm'] || detected.deps['@neondatabase/serverless'])) add('database_url_unused', 'warn', 'A database driver is present but DATABASE_URL is not read', { fix: 'Connect with process.env.DATABASE_URL; provisioning writes it per environment.' });
if (neon || detected.deps['@neondatabase/serverless']) add('neon_serverless_driver', 'blocker', 'Uses the Neon serverless driver, which only speaks to Neon over HTTP/WebSocket', { fix: "Switch to 'pg' with drizzle-orm/node-postgres (npm i pg && npm rm @neondatabase/serverless ws)." });
const drizzleCfg = ['drizzle.config.ts', 'drizzle.config.js', 'drizzle.config.mjs'].find((f) => existsSync(join(dir, f)));
if (drizzleCfg) {
detected.drizzle = true;
detected.drizzleSql = existsSync(join(dir, 'drizzle', 'meta', '_journal.json'));
if (!detected.drizzleSql) add('drizzle_no_generated_sql', 'warn', 'Drizzle is configured but no generated SQL is committed; drizzle-kit push cannot reach the account database', { fix: 'Add "drizzle-kit generate" to the build script, commit drizzle/, add "drizzle" to deploy.artifacts and set deploy.migrate to "node --env-file=.env scripts/awesomate-migrate.mjs".' });
} else if (existsSync(join(dir, 'migrations'))) {
detected.migrationsDir = 'migrations';
}
if (replitDeps.length) add('replit_dev_deps', 'warn', `Replit-only packages present: ${replitDeps.join(', ')}`, { fix: 'Remove them from package.json and vite.config.ts; they do nothing outside Replit.' });
if (hasReplitFiles) add('replit_config_files', 'info', '.replit / replit.nix present', { fix: 'Harmless; delete when the move is done.' });
if (replEnv.length) add('replit_env_reads', 'warn', `Code reads Replit-only variables: ${replEnv.join(', ')}`, { fix: 'Replace with your own configuration or remove; they are unset on Awesomate.' });
if (replitAuth) add('replit_auth', 'warn', 'Replit Auth (OIDC against replit.com) is used for sign-in', { fix: 'It stops working off Replit. Replace with your own auth (session + password, or another OIDC provider).' });
if (ws) add('websockets_proxy', 'info', 'WebSockets are used', { fix: 'Nothing to do: the reverse proxy forwards the Upgrade handshake on every environment address and attached hostname.' });
if (evalUse) add('dynamic_code_eval', 'warn', 'eval() or new Function() in source', { fix: 'The prod security gate blocks these; replace before merging to main.' });
if (detected.node && !/(^|[^0-9])22([^0-9]|$)|>=\s*(1[8-9]|2[0-2])/.test(String(detected.node))) add('node_engine_mismatch', 'warn', `engines.node is "${detected.node}"; Awesomate runs Node 22`, { fix: 'Test on Node 22 locally; update engines.' });
if (pkg.scripts?.start && /NODE_ENV=/.test(pkg.scripts.start)) add('start_sets_node_env', 'info', 'start script sets NODE_ENV inline', { fix: 'Not needed: the account .env sets NODE_ENV=production. deploy.start is the entry file, e.g. dist/index.js.' });
// The Vite pattern that killed careconnect's first deploy: dev-only middleware
// behind a NODE_ENV check, imported at the top level. NODE_ENV is "production"
// on every Awesomate environment, so the dev branch never runs; a TOP-LEVEL
// import of vite still fails at startup because vite is not installed.
const viteServerFiles = files.filter((f) => /import[^;]*['"]vite['"]|from ['"]vite['"]|require\(['"]vite['"]\)/.test((() => { try { return readFileSync(f, 'utf8'); } catch { return ''; } })()));
for (const f of viteServerFiles) {
const rel = relative(dir, f);
if (/^(vite\.config|client\/)/.test(rel)) continue;
add('vite_imported_at_runtime', 'blocker', `${rel} imports vite, which is a devDependency and is NOT installed on the server`, {
file: rel,
fix: "Import vite only inside the branch that needs it (await import('vite') behind a NODE_ENV !== 'production' check), or better: serve the built client with express.static and delete the dev-server path from the deployed bundle. Awesomate sets NODE_ENV=production on every environment (use AWESOMATE_ENV to tell dev from prod).",
});
}
if (existsSync(join(dir, 'main.py'))) add('python_file_present', 'info', 'main.py present', { fix: 'Only Node runs on the account; delete it if it is a leftover.' });
for (const name of readdirSync(dir, { withFileTypes: true })) {
if (!name.isDirectory() || SKIP.has(name.name) && name.name !== 'attached_assets') continue;
let size = 0, count = 0;
(function du(p, depth) { if (depth > 6) return; for (const e of readdirSync(p, { withFileTypes: true })) { const q = join(p, e.name); if (e.isDirectory()) du(q, depth + 1); else { count += 1; try { size += statSync(q).size; } catch { /* ignore */ } } } })(join(dir, name.name), 0);
if (size > 50 * 1024 * 1024) { detected.largeDirs.push({ dir: name.name, mb: Math.round(size / 1048576), files: count }); add('large_assets_dir', 'warn', `${name.name}/ is ${Math.round(size / 1048576)} MB (${count} files)`, { fix: 'Do not ship it through the deploy; serve static assets from public_html or object storage and keep the repo small.' }); }
}
// --- suggested deploy contract ---------------------------------------------------
const artifacts = ['dist', 'package.json', 'package-lock.json'];
if (detected.drizzle) artifacts.splice(1, 0, 'drizzle');
else if (detected.migrationsDir) artifacts.splice(1, 0, 'migrations');
deploy = {
node: 22,
build: detected.drizzle && !/drizzle-kit generate/.test(detected.build ?? '') ? 'npx drizzle-kit generate && npm run build' : 'npm run build',
artifacts,
install: 'npm ci --omit=dev',
migrate: detected.drizzle ? 'node --env-file=.env scripts/awesomate-migrate.mjs' : detected.migrationsDir ? 'node --env-file=.env scripts/awesomate-migrate.mjs migrations' : null,
start: 'dist/index.js',
ready_path: '/api/ready',
};
finish(platform);
function finish(plat) {
const blockers = findings.filter((f) => f.severity === 'blocker').length;
const report = { version: 1, platform: plat, findings, detected, deploy, summary: { blockers, warnings: findings.filter((f) => f.severity === 'warn').length, fixApplied: fix } };
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}