fix: improve SSH command execution in deploy service

- Prefix all nvm-dependent commands with NVM source via withNvm() helper
- Fix .env file writing using printf instead of heredoc (which does not work over SSH exec)
- Fix git clone URL encoding using URL class for proper credential encoding
- Add DEBIAN_FRONTEND=noninteractive to apt-get for non-interactive installs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:25:07 +02:00
parent 11ddbae757
commit 46cd910079
+27 -17
View File
@@ -252,12 +252,15 @@ async function firstDeploy(
try { try {
if (repoConfig.aptPackages.length > 0) { if (repoConfig.aptPackages.length > 0) {
await runSshStep(previewId, sshSession, `sudo apt-get install -y ${repoConfig.aptPackages.join(" ")}`); await runSshStep(previewId, sshSession, `sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ${repoConfig.aptPackages.join(" ")}`);
} }
const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : ""; const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
const authCloneUrl = cloneUrl.replace("https://", `https://${user.giteaUsername}:${giteaPat}@`); const parsedUrl = new URL(cloneUrl.startsWith("http") ? cloneUrl : `https://${cloneUrl}`);
await runSshStep(previewId, sshSession, `git clone ${authCloneUrl} /opt/app`); parsedUrl.username = encodeURIComponent(user.giteaUsername || "");
parsedUrl.password = encodeURIComponent(giteaPat);
const authCloneUrl = parsedUrl.toString();
await runSshStep(previewId, sshSession, `git clone '${authCloneUrl}' /opt/app`);
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`); await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true); await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true);
@@ -334,6 +337,12 @@ async function redeploy(
} }
} }
const NVM_PREFIX = `export NVM_DIR="/root/.nvm"; source "$NVM_DIR/nvm.sh" 2>/dev/null;`;
function withNvm(cmd: string): string {
return `bash -c '${NVM_PREFIX} ${cmd.replace(/'/g, `'"'"'`)}'`;
}
async function setupAndBuild( async function setupAndBuild(
previewId: number, previewId: number,
sshSession: SshSession, sshSession: SshSession,
@@ -342,15 +351,22 @@ async function setupAndBuild(
commitSha: string, commitSha: string,
isFirstProvision: boolean, isFirstProvision: boolean,
) { ) {
await detectAndUseNode(previewId, sshSession); // Detect and use Node version
const nvmCmd = `${NVM_PREFIX} if [ -f /opt/app/.nvmrc ]; then nvm install && nvm use; else nvm use default; fi`;
await runSshStep(previewId, sshSession, `bash -c '${nvmCmd}'`, false);
// Write .env file
const envVars = repoConfig.envVars as Record<string, string>; const envVars = repoConfig.envVars as Record<string, string>;
const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n"); const envLines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\\n");
await runSshStep(previewId, sshSession, `cat > /opt/app/.env << 'PPEOF'\n${envContent}\nPPEOF`); if (envLines) {
await runSshStep(previewId, sshSession, `printf '${envLines}\\n' > /opt/app/.env`, false);
} else {
await runSshStep(previewId, sshSession, `touch /opt/app/.env`, false);
}
if (isFirstProvision) { if (isFirstProvision) {
for (const cmd of repoConfig.setupCommands) { for (const cmd of repoConfig.setupCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); await runSshStep(previewId, sshSession, withNvm(`cd /opt/app && ${cmd}`));
} }
} }
@@ -361,16 +377,15 @@ async function setupAndBuild(
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`); await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`);
} else { } else {
for (const cmd of repoConfig.buildCommands) { for (const cmd of repoConfig.buildCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); await runSshStep(previewId, sshSession, withNvm(`cd /opt/app && ${cmd}`));
} }
for (const cmd of repoConfig.postBuildCommands) { for (const cmd of repoConfig.postBuildCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`); await runSshStep(previewId, sshSession, withNvm(`cd /opt/app && ${cmd}`));
} }
if (repoConfig.runCommand) { if (repoConfig.runCommand) {
const res = await runSshStep(previewId, sshSession, const startCmd = withNvm(`cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!`);
`cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!` const res = await runSshStep(previewId, sshSession, startCmd);
);
const pid = parseInt(res.stdout.trim(), 10); const pid = parseInt(res.stdout.trim(), 10);
if (!isNaN(pid)) { if (!isNaN(pid)) {
await prisma.preview.update({ where: { id: previewId }, data: { pid } }); await prisma.preview.update({ where: { id: previewId }, data: { pid } });
@@ -379,11 +394,6 @@ async function setupAndBuild(
} }
} }
async function detectAndUseNode(previewId: number, sshSession: SshSession) {
const nvmSource = `export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh"`;
const res = await runSshStep(previewId, sshSession, `${nvmSource} && [ -f /opt/app/.nvmrc ] && nvm install && nvm use || nvm use default 2>&1`, false);
}
async function runSshStep(previewId: number, sshSession: SshSession, command: string, throwOnFail = true) { async function runSshStep(previewId: number, sshSession: SshSession, command: string, throwOnFail = true) {
checkAbortSession(sshSession); checkAbortSession(sshSession);
await appendLog(previewId, `$ ${command}\n`); await appendLog(previewId, `$ ${command}\n`);