Files
rocketry/install-service.sh
2026-03-03 20:30:29 +00:00

210 lines
5.9 KiB
Bash

#!/usr/bin/env bash
set -e
# Rocketry Service Installer
# Installs the React SPA as a systemd service on the system
# Preflight checks
command -v node >/dev/null || { echo "Error: node not found"; exit 1; }
command -v npm >/dev/null || { echo "Error: npm not found"; exit 1; }
command -v sudo >/dev/null || { echo "Error: sudo not found"; exit 1; }
# Detect node path and current user
NODE_PATH=$(which node)
CURRENT_USER=$(whoami)
echo "Rocketry Service Installer"
echo "=========================="
echo ""
echo "Node path: $NODE_PATH"
echo "Service user: $CURRENT_USER"
echo ""
# Port prompt
read -p "Port to run on [default: 8080]: " PORT
PORT=${PORT:-8080}
# Validate port
if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "Error: Invalid port number. Must be between 1 and 65535."
exit 1
fi
echo ""
echo "Building application..."
npm run build
# Install directory
INSTALL_DIR="/opt/rocketry"
echo "Installing to $INSTALL_DIR..."
sudo mkdir -p "$INSTALL_DIR"
sudo cp -r dist "$INSTALL_DIR/"
# Write server.js to /opt/rocketry/server.js
echo "Writing server.js..."
sudo tee "$INSTALL_DIR/server.js" > /dev/null <<'SERVEREOF'
#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 8080;
const DIST_DIR = path.join(__dirname, 'dist');
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.json': 'application/json',
};
const server = http.createServer((req, res) => {
// Normalize URL
let urlPath = decodeURIComponent(req.url);
if (urlPath === '/') {
urlPath = '/index.html';
}
// Resolve file path
const filePath = path.join(DIST_DIR, urlPath);
// Security check: ensure we're within DIST_DIR
if (!filePath.startsWith(DIST_DIR)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden');
return;
}
fs.stat(filePath, (err, stats) => {
if (err) {
// File not found; serve index.html for SPA routing (unless it's a file with extension)
const ext = path.extname(urlPath);
if (ext && ext !== '.html') {
// Likely a missing asset file
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
// SPA fallback: serve index.html
const indexPath = path.join(DIST_DIR, 'index.html');
fs.readFile(indexPath, 'utf-8', (readErr, data) => {
if (readErr) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(data);
});
return;
}
if (stats.isDirectory()) {
// Try to serve index.html from the directory
const indexPath = path.join(filePath, 'index.html');
fs.readFile(indexPath, 'utf-8', (readErr, data) => {
if (readErr) {
// Directory exists but no index.html; fall back to SPA index
const mainIndexPath = path.join(DIST_DIR, 'index.html');
fs.readFile(mainIndexPath, 'utf-8', (mainErr, mainData) => {
if (mainErr) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(mainData);
});
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(data);
});
return;
}
// Serve file with appropriate MIME type
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
fs.readFile(filePath, (readErr, data) => {
if (readErr) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
return;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
});
});
server.listen(PORT, () => {
console.log(`Rocketry server listening on http://localhost:${PORT}`);
});
server.on('error', (err) => {
if (err.code === 'EACCES') {
console.error(`Error: Permission denied. Port ${PORT} may require elevated privileges.`);
} else if (err.code === 'EADDRINUSE') {
console.error(`Error: Port ${PORT} is already in use.`);
} else {
console.error('Server error:', err.message);
}
process.exit(1);
});
SERVEREOF
# Make server.js executable
sudo chmod +x "$INSTALL_DIR/server.js"
# Write systemd unit file
echo "Writing systemd service file..."
sudo bash -c "cat > /etc/systemd/system/rocketry.service" <<UNITEOF
[Unit]
Description=Rocketry Equation Solver
After=network.target
[Service]
Type=simple
User=$CURRENT_USER
WorkingDirectory=/opt/rocketry
ExecStart=$NODE_PATH /opt/rocketry/server.js
Environment="PORT=$PORT"
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
UNITEOF
# Enable and start service
echo "Configuring systemd service..."
sudo systemctl daemon-reload
sudo systemctl enable rocketry
sudo systemctl restart rocketry
echo ""
echo "✓ Rocketry service installed successfully!"
echo ""
echo " Location: $INSTALL_DIR"
echo " Port: $PORT"
echo " URL: http://localhost:$PORT"
echo ""
echo "Service status:"
sudo systemctl status rocketry --no-pager | sed 's/^/ /'
echo ""
echo "Useful commands:"
echo " Check status: sudo systemctl status rocketry"
echo " View logs: sudo journalctl -u rocketry -f"
echo " Restart: sudo systemctl restart rocketry"
echo " Stop: sudo systemctl stop rocketry"
echo " Uninstall: sudo systemctl disable rocketry && sudo rm -rf $INSTALL_DIR /etc/systemd/system/rocketry.service && sudo systemctl daemon-reload"