From ef68150d8c4a7b566ff9f55e7c9097a5f2dea719 Mon Sep 17 00:00:00 2001 From: "M. Willis Monroe" Date: Wed, 4 Feb 2026 12:36:22 -0400 Subject: [PATCH] Add Dokku deployment --- app/back-end/modules/deploy/deployment.js | 3 + app/back-end/modules/deploy/dokku.js | 276 ++++++++++++++++++ .../default-languages/en-gb/translations.json | 6 + app/src/assets/svg/svg-map-server.svg | 5 + app/src/components/ServerSettings.vue | 133 ++++++++- app/src/components/SyncPopup.vue | 17 ++ .../configs/defaultDeploymentSettings.js | 7 + 7 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 app/back-end/modules/deploy/dokku.js diff --git a/app/back-end/modules/deploy/deployment.js b/app/back-end/modules/deploy/deployment.js index f0a7b7a0..b2ad2599 100644 --- a/app/back-end/modules/deploy/deployment.js +++ b/app/back-end/modules/deploy/deployment.js @@ -16,6 +16,7 @@ const GitlabPages = require('./gitlab-pages.js'); const Netlify = require('./netlify.js'); const GoogleCloud = require('./google-cloud.js'); const ManualDeployment = require('./manual.js'); +const Dokku = require('./dokku.js'); /** * @@ -73,6 +74,7 @@ class Deployment { case 'git': connection = new Git(); break; case 'github-pages': connection = new GithubPages(deploymentConfig); break; case 'gitlab-pages': connection = new GitlabPages(); break; + case 'dokku': connection = new Dokku(); break; default: if (this.useAltFtp) { connection = new FTPAlt(); @@ -101,6 +103,7 @@ class Deployment { case 'netlify': this.client = new Netlify(this); break; case 'google-cloud': this.client = new GoogleCloud(this); break; case 'manual': this.client = new ManualDeployment(this); break; + case 'dokku': this.client = new Dokku(this); break; default: if (this.useAltFtp) { this.client = new FTPAlt(this); diff --git a/app/back-end/modules/deploy/dokku.js b/app/back-end/modules/deploy/dokku.js new file mode 100644 index 00000000..3d3382d9 --- /dev/null +++ b/app/back-end/modules/deploy/dokku.js @@ -0,0 +1,276 @@ +/* + * Class used to upload files to a Dokku server + */ + +const { spawn } = require('child_process'); +const fs = require('fs-extra'); +const path = require('path'); +const stripTags = require('striptags'); + +class Dokku { + constructor(deploymentInstance = false) { + this.deployment = deploymentInstance; + this.url = ''; + this.branch = ''; + this.commitAuthor = ''; + this.commitEmail = ''; + this.commitMessage = ''; + } + + async initConnection() { + this.url = this.deployment.siteConfig.deployment.dokku.url; + this.branch = this.deployment.siteConfig.deployment.dokku.branch || 'main'; + this.commitAuthor = this.deployment.siteConfig.deployment.dokku.commitAuthor; + this.commitEmail = this.deployment.siteConfig.deployment.dokku.commitEmail || ''; + this.commitMessage = this.deployment.siteConfig.deployment.dokku.commitMessage || 'Publii: update content'; + + process.send({ + type: 'web-contents', + message: 'app-uploading-progress', + value: { + progress: 6, + operations: false + } + }); + + process.send({ + type: 'web-contents', + message: 'app-connection-in-progress' + }); + + this.deployment.setInput(); + await this.deploy(); + } + + async testConnection(app, deploymentConfig, siteName, uuid) { + let self = this; + let url = deploymentConfig.dokku.url; + self.waitForTimeout = true; + + if (!url) { + app.mainWindow.webContents.send('app-deploy-test-error', { + message: 'Dokku URL is not configured' + }); + return; + } + + let urlMatch = url.match(/^[^@]+@([^:]+):/); + + if (!urlMatch) { + app.mainWindow.webContents.send('app-deploy-test-error', { + message: 'Invalid Dokku URL format. Expected: dokku@server:app-name' + }); + return; + } + + let host = urlMatch[1]; + let ssh = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-T', `dokku@${host}`]); + let stdout = ''; + let stderr = ''; + + ssh.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + ssh.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + let timeoutCheck = setTimeout(function() { + if (self.waitForTimeout === true) { + ssh.kill(); + app.mainWindow.webContents.send('app-deploy-test-error', { + message: { + translation: 'core.server.requestTimeout' + } + }); + self.waitForTimeout = false; + } + }, 15000); + + ssh.on('close', (code) => { + clearTimeout(timeoutCheck); + + if (stderr.includes('Permission denied') || stderr.includes('Host key verification failed')) { + app.mainWindow.webContents.send('app-deploy-test-error', { + message: stderr.includes('Host key verification failed') + ? 'Host key verification failed. Add the server to your known_hosts file.' + : 'SSH authentication failed. Verify your SSH key is added to the Dokku server.' + }); + } else if (stdout.includes('dokku') || stderr.includes('dokku') || code === 1) { + app.mainWindow.webContents.send('app-deploy-test-success'); + } else if (code === 255) { + app.mainWindow.webContents.send('app-deploy-test-error', { + message: 'Could not connect to the server. Check the hostname and your network connection.' + }); + } else { + app.mainWindow.webContents.send('app-deploy-test-success'); + } + + self.waitForTimeout = false; + }); + + ssh.on('error', (err) => { + clearTimeout(timeoutCheck); + app.mainWindow.webContents.send('app-deploy-test-error', { + message: stripTags(err.message) + }); + self.waitForTimeout = false; + }); + } + + async deploy() { + let self = this; + + try { + let dir = this.deployment.inputDir; + + await this.execGit(dir, ['init', '-b', this.branch]); + console.log('[i] Dokku debug: git init done'); + + await this.execGit(dir, ['config', 'user.name', this.commitAuthor]); + + if (this.commitEmail) { + await this.execGit(dir, ['config', 'user.email', this.commitEmail]); + } + + console.log('[i] Dokku debug: git config done'); + + fs.writeFileSync(path.join(dir, '.static'), ''); + console.log('[i] Dokku debug: .static file created'); + + let siteDir = path.join(dir, '_site'); + + if (!fs.existsSync(siteDir)) { + fs.mkdirSync(siteDir); + } + + let filesToMove = fs.readdirSync(dir).filter(f => f !== '.git' && f !== '_site' && f !== '.static'); + + for (let i = 0; i < filesToMove.length; i++) { + fs.moveSync(path.join(dir, filesToMove[i]), path.join(siteDir, filesToMove[i]), { overwrite: true }); + } + + console.log('[i] Dokku debug: moved files to _site/'); + + let remotes = await this.execGit(dir, ['remote', '-v']); + + if (remotes.stdout.includes('dokku')) { + if (!remotes.stdout.includes(this.url)) { + await this.execGit(dir, ['remote', 'set-url', 'dokku', this.url]); + console.log('[i] Dokku debug: remote URL updated'); + } + } else { + await this.execGit(dir, ['remote', 'add', 'dokku', this.url]); + console.log('[i] Dokku debug: remote added'); + } + + await this.execGit(dir, ['add', '-A']); + console.log('[i] Dokku debug: git add done'); + + let status = await this.execGit(dir, ['status', '--porcelain']); + let hasChanges = status.stdout.trim().length > 0; + + console.log('[i] Dokku debug: changes exists = ', hasChanges); + + if (hasChanges) { + await this.execGit(dir, ['commit', '-m', this.commitMessage]); + console.log('[i] Dokku debug: commit done'); + + process.send({ + type: 'web-contents', + message: 'app-uploading-progress', + value: { + message: 'Pushing changes to remote...', + progress: 50, + operations: [0, 1] + } + }); + + await this.execGit(dir, ['push', 'dokku', this.branch, '--force']); + console.log('[i] Dokku debug: push done'); + + process.send({ + type: 'web-contents', + message: 'app-uploading-progress', + value: { + message: 'Push operation completed', + progress: 99, + operations: [1, 1] + } + }); + } + + process.send({ + type: 'web-contents', + message: 'app-uploading-progress', + value: { + progress: 100, + operations: false + } + }); + + process.send({ + type: 'sender', + message: 'app-deploy-uploaded', + value: { + progress: 100, + status: true + } + }); + + setTimeout(function () { + process.kill(process.pid, 'SIGTERM'); + }, 1000); + } catch (err) { + console.log(`[${ new Date().toUTCString() }] ERROR: ${err}`); + + process.send({ + type: 'web-contents', + message: 'app-connection-error', + value: { + additionalMessage: 'Critical error: ' + stripTags((err).toString()) + } + }); + + setTimeout(function () { + process.kill(process.pid, 'SIGTERM'); + }, 1000); + } + } + + execGit(dir, args) { + return new Promise((resolve, reject) => { + let git = spawn('git', args, { + cwd: dir, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } + }); + + let stdout = ''; + let stderr = ''; + + git.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + git.stderr.on('data', (data) => { + stderr += data.toString(); + console.log('[i] Dokku debug: ' + data.toString().trim()); + }); + + git.on('close', (code) => { + if (code === 0 || args[0] === 'status' || args[0] === 'add' || args[0] === 'remote') { + resolve({ stdout, stderr }); + } else { + reject(new Error(stderr || stdout || `Git command failed: git ${args.join(' ')}`)); + } + }); + + git.on('error', (err) => { + reject(err); + }); + }); + } +} + +module.exports = Dokku; diff --git a/app/default-files/default-languages/en-gb/translations.json b/app/default-files/default-languages/en-gb/translations.json index 2879a533..013d8be7 100644 --- a/app/default-files/default-languages/en-gb/translations.json +++ b/app/default-files/default-languages/en-gb/translations.json @@ -1125,6 +1125,7 @@ "deploymentMethodFilesPubliiMsg": "Selected deployment method uses files.publii.json file for sync. Consider access protection for this file in your server configuration if required - read more", "deploymentMethodFtpMsg": "FTP protocol uses an unencrypted transmission, which means any data sent over it, including your username and password, could be read by anyone who may intercept your transmission. We strongly recommend using FTPS or SFTP protocols if possible.", "deploymentMethodGitMsg": "For detailed information about how to configure a website using Git, check Publii's online documentation.", + "deploymentMethodDokkuMsg": "Dokku deployment uses SSH authentication. Ensure your SSH key is configured on the Dokku server. For more information about Dokku, visit Dokku documentation.", "deploymentMethodGithubPagesMsg": "For detailed information about how to configure a website using Github Pages, check Publii's online documentation.", "deploymentMethodGitNote": "Please remember, that if you are using custom domain, you must put CNAME file under File Manager in the root files", "deploymentMethodGithubPagesNote": "This will be your Github repository path, which should use the following format: YOUR_USERNAME.github.io/YOUR_REPOSITORY_NAME.
If you are using a custom domain name, set this field to just the custom domain name.", @@ -1137,6 +1138,11 @@ "deploymentSettingFileProtocolNote": "The \"file://\" protocol is useful only if you are using the manual deployment method for intranet websites.", "deploymentSettingRelativeUrlsNote": "Note: while using relative URLs, some features like Open Graph tags, sitemaps, RSS feeds, JSON feeds etc. will be disabled.", "deprecated": "Deprecated", + "dokku": "Dokku", + "dokkuBranchExampleNote": "Branch to push to Dokku. Default: main", + "dokkuUrl": "Dokku Git URL", + "dokkuUrlFieldCantBeEmpty": "The Dokku URL field cannot be empty", + "dokkuUrlNote": "Format: dokku@server:app-name (e.g., dokku@example.com:my-site)", "destinationServerNotConfiguredErrorMessage": "Your website cannot currently be synced as the destination server has not been configured correctly.
Check your server settings to ensure that the correct information has been entered.", "destinationServerNotConfiguredErrorText": "Make sure the destination server is properly configured.", "domainNameNotSetErrorMessage": "Your website cannot currently be synced as the settings appear to lack a domain name.
Check your server settings to ensure a domain name has been entered.", diff --git a/app/src/assets/svg/svg-map-server.svg b/app/src/assets/svg/svg-map-server.svg index d1eb3256..b889a164 100755 --- a/app/src/assets/svg/svg-map-server.svg +++ b/app/src/assets/svg/svg-map-server.svg @@ -31,5 +31,10 @@ + + + + + diff --git a/app/src/components/ServerSettings.vue b/app/src/components/ServerSettings.vue index 2717a956..d4157b58 100644 --- a/app/src/components/ServerSettings.vue +++ b/app/src/components/ServerSettings.vue @@ -94,7 +94,19 @@ iconset="svg-map-server"/> {{ $t('sync.git') }} - + +
+ + {{ $t('sync.dokku') }} +
+
- + + + + @@ -751,6 +768,108 @@ + + + + {{ $t('sync.dokkuUrlFieldCantBeEmpty') }} + + + {{ $t('sync.dokkuUrlNote') }} + + + + + + + {{ $t('sync.branchFieldCantBeEmpty') }} + + + + + + + + + {{ $t('sync.commitAuthorFieldCantBeEmpty') }} + + + + + + + + + + + {{ $t('sync.commitMessageFieldCantBeEmpty') }} + + +