aiom
Version:
A Highly Flexible and Modular Framework for Behavioral Experiments
322 lines (293 loc) • 12.7 kB
JavaScript
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { exec, spawn } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
const e = require('express');
const experimentDir = process.cwd();
const arg = process.argv.slice(2)[0];
const runCommand = async (command, args = [], options = {}) => {
return new Promise((resolve, reject) => {
console.log(`Running: ${command} ${args.join(' ')}`);
const process = spawn(command, args, {
shell: true,
cwd: experimentDir,
...options
});
process.stdout.on('data', (data) => {
console.log(data.toString());
});
process.stderr.on('data', (data) => {
console.log(data.toString());
});
process.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command exited with code ${code}`));
}
});
});
};
async function deploy() {
console.log('Starting Heroku deployment...');
// check if dockerfile and heroku.yml exist
if (!fs.existsSync(path.join(experimentDir, 'heroku.yml'))) {
fs.cpSync(
path.join(__dirname, '..', 'deploy'),
experimentDir,
{ recursive: true, force: true }
);
console.log('heroku.yml and Dockerfile generated!.');
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
function ask(question) {
return new Promise(resolve => rl.question(question, resolve));
}
const studyName = path.basename(experimentDir);
const appNameInput = await ask(`App name (press ENTER to use your study name '${studyName}'): `);
const appName = appNameInput.trim() === '' ? studyName : appNameInput.trim();
const region = await ask('Loaction of the App (us/eu): ');
const dyno_type = await ask('Dyno type (eco/basic/standard-1x/standard-2x/...): ');
const db_plan = await ask('Database plan (essential-0/essential-1/essential-2/standard-0/...): ');
let PROLIFIC_API_KEY;
if (process.env.prolific === 'true') {
PROLIFIC_API_KEY = await ask('Prolific API key: ');
}
// Execute commands in sequence
(async () => {
try {
// Check if already logged in
try {
await runCommand('heroku', ['auth:whoami']);
console.log('Already logged in to Heroku');
} catch (e) {
console.log('Need to login to Heroku');
const loginPromise = new Promise((resolve, reject) => {
const loginProcess = spawn('heroku', ['login'], {
shell: true,
cwd: experimentDir,
stdio: 'pipe' // Important to enable stdin
});
loginProcess.stdout.on('data', (data) => {
console.log(data.toString());
resolve();
});
loginProcess.stderr.on('data', (data) => {
const output = data.toString();
console.log(output);
// Look for the login prompt
if (output.includes('Press') && output.includes('browser')) {
console.log('Automatically opening browser for login...');
loginProcess.stdin.write('\n');
}
});
});
await loginPromise;
}
// Check if app exists
try {
await runCommand('heroku', ['apps:info', '--app', appName]);
console.log(`App ${appName} already exists`);
} catch (e) {
console.log(`Creating app ${appName} in region ${region}...`);
await runCommand('heroku', ['apps:create', appName, '--region', region]);
}
try {
if (process.env.prolific === 'true') {
console.log(`Setting up Prolific API key as ${PROLIFIC_API_KEY}`);
await runCommand('heroku', ['config:set', `PROLIFIC_API_KEY=${PROLIFIC_API_KEY}`, '--app', appName]);
}
// Set stack to container
await runCommand('heroku', ['stack:set', 'container', '--app', appName]);
} catch (error) {
console.log(`Error: ${error.message}`);
}
// Create PostgreSQL addon if not exists
let isProvisioned = false;
let attempts = 0;
const maxAttempts = 36;
// Check if DATABASE_URL exists in the config
const { stdout } = await execPromise('heroku config --app ' + appName, {
cwd: experimentDir
});
if (stdout.includes('DATABASE_URL')) {
console.log('PostgreSQL is now fully provisioned and ready to use.');
isProvisioned = true;
} else {
console.log('PostgreSQL not set yet)');
console.log('Creating PostgreSQL addon...');
await runCommand('heroku', ['addons:create', `heroku-postgresql:${db_plan}`, '--app', appName]);
while (!isProvisioned && attempts < maxAttempts) {
attempts++;
console.log(`Checking PostgreSQL status (attempt ${attempts}/${maxAttempts})...`);
try {
// Check if DATABASE_URL exists in the config
const { stdout } = await execPromise('heroku config --app ' + appName, {
cwd: experimentDir
});
if (stdout.includes('DATABASE_URL')) {
console.log('PostgreSQL is now fully provisioned and ready to use.');
isProvisioned = true;
break;
} else {
console.log('PostgreSQL still provisioning');
}
} catch (error) {
console.log(`Error checking status: ${error.message}`);
}
// Wait 5 seconds before checking again
await new Promise(resolve => setTimeout(resolve, 6000));
}
}
if (!isProvisioned) {
console.log('Warning: Timed out waiting for PostgreSQL. Continuing anyway, but deployment might not work properly.');
}
// Initialize git if needed
if (!fs.existsSync(path.join(experimentDir, '.git'))) {
await runCommand('git', ['init']);
await runCommand('git', ['add', '.']);
await runCommand('git', ['commit', '-m "Initial commit for Heroku deployment"']);
console.log('Initialized git repository automatically');
} else {
await runCommand('git', ['add', '.']);
await runCommand('git', ['commit', '-m "Default update for Heroku deployment"']);
console.log('Updated git repository automatically');
}
// remember to perform 'heroku authorizations:create' to create a token
const { stdout: tokenOutput } = await execPromise('heroku auth:token', {
cwd: experimentDir
});
// Clean any whitespace from the token
const herokuToken = tokenOutput.trim();
// Add Heroku remote if needed
try {
await runCommand('git', ['remote', 'remove', 'heroku']);
} catch (e) {
// Ignore error if remote does not exist
}
const remoteUrl = `https://heroku:${herokuToken}@git.heroku.com/${appName}.git`;
await runCommand('git', ['remote', 'add', 'heroku', remoteUrl]);
// Push to Heroku
await runCommand('git', ['push', 'heroku', 'main:main', '--force']);
console.log('Deployment completed successfully!');
// Set dyno plan
const { stdout: dyno_plan } = await execPromise('heroku ps:type ' + dyno_type + ' --app ' + appName, {
cwd: experimentDir
});
console.log(`set dyno_plan as ${dyno_plan}`);
// Get the actual app URL
const { stdout: appInfoOutput } = await execPromise(`heroku info --app ${appName}`, {
cwd: experimentDir
});
let appUrl = `https://${appName}`;
const webUrlMatch = appInfoOutput.match(/Web URL:\s+(https:\/\/[^\s]+)/);
if (webUrlMatch && webUrlMatch[1]) {
appUrl = webUrlMatch[1];
}
console.log(`Your app is now available at: ${appUrl}`);
console.log('Press Ctrl+C to exit this process.');
} catch (error) {
console.log(`Error: ${error.message}`);
}
})();
};
async function download() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
function ask(question) {
return new Promise(resolve => rl.question(question, resolve));
}
try {
console.log('All Heroku apps:');
const { stdout } = await execPromise('heroku apps --json', {
cwd: experimentDir
});
const appnames = JSON.parse(stdout);
// appnames.forEach(app => console.log(` - ${app}`));
appnames.forEach(app => console.log(` - ${app.name}`));
const appName = await ask('App you wish to download data from: ');
const command_capture = `heroku pg:backups:capture -a ${appName}`;
const command_download = `heroku pg:backups:download -a ${appName} -o ./db_export/${appName}.dump`
const { stdout: captureOutput, stderr: captureError } = await execPromise(command_capture, {
shell: true,
cwd: experimentDir
});
console.log('Database backup captured from heroku successfully! Start downloading...');
const { stdout: downloadOutput, stderr: downloadError } = await execPromise(command_download, {
shell: true,
cwd: experimentDir
});
console.log('Database backup downloaded from heroku successfully!');
// download csv files
const exportDir = path.join(experimentDir, 'db_export');
console.log('Fetching table list...');
const { stdout: tablesOutput } = await execPromise(
`heroku pg:psql -a ${appName} -c "SELECT string_agg(tablename, ',') FROM pg_tables WHERE schemaname = 'public';"`,
{ shell: true, cwd: experimentDir }
);
const lines = tablesOutput.split('\n');
let tablesString = '';
for (const line of lines) {
const trimmedLine = line.trim();
// Look for the line containing comma-separated table names
if (trimmedLine &&
!trimmedLine.includes('string_agg') &&
!trimmedLine.includes('---') &&
!trimmedLine.includes('row') &&
!trimmedLine.includes('(') &&
trimmedLine.length > 0) {
tablesString = trimmedLine;
break;
}
}
// Parse the comma-separated tables
const tables = tablesString
? tablesString.split(',').map(t => t.trim()).filter(t => t && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(t))
: [];
if (tables.length === 0) {
console.log('No tables found in the database.');
return;
}
console.log(`Found tables: ${tables.join(', ')}`);
console.log('Exporting tables to CSV...');
// Export each table to CSV
for (const table of tables) {
const csvFilePath = path.join(exportDir, `heroku_${table}.csv`);
const command = `heroku pg:psql -a ${appName} -c "COPY (SELECT * FROM ${table}) TO STDOUT WITH CSV HEADER;" > "${csvFilePath}"`;
await execPromise(command, { shell: true, cwd: experimentDir });
// Post-process to quote timestamp columns (prevents Excel reformatting)
let csvContent = fs.readFileSync(csvFilePath, 'utf-8');
csvContent = csvContent.replace(/(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}(?:\.\d+)?)/g, "GMT$1");
fs.writeFileSync(csvFilePath, csvContent);
console.log(`Exported ${table} to ${csvFilePath}`);
}
console.log('CSV export completed successfully!');
} catch (error) {
console.log(`Error during download: ${error.message}`);
// throw error;
} finally {
rl.close();
}
};
// async function main() {
// if (arg === 'deploy') {
// await deploy();
// } else if (arg === 'download') {
// await download();
// } else {
// console.log('Usage: node heroku.js <deploy|download>');
// console.log('npm run heroku deploy - Deploy the app to Heroku');
// console.log('npm run heroku download - Download data from a Heroku app');
// }
// }
module.exports = {
deploy,
download
};