From 064f6b62b3f681e6ac0fdeaa62123fad05e05a23 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 3 Oct 2023 20:04:04 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20support=20for=20multiple=20?= =?UTF-8?q?built-in=20themes=20(#1784)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs TryGhost/Product#3510 - Until now, Ghost has always shipped with a single built-in theme, Casper. This change adds support for Ghost to ship with multiple built-in themes. - Updated the install and update commands to create symlinks for any theme that is shipped alongside Ghost (e.g. any theme in `ghost/core/content/themes`) - When rolling back with `ghost update --rollback`, any symlinks that are broken in the process will be removed. A migration in Ghost itself will change the active_theme back to Casper if the currently active_theme is no longer installed in the previous version of Ghost Co-authored-by: Vikas Potluri --- lib/commands/install.js | 22 ++++++---- lib/commands/update.js | 35 +++++++++++++++- lib/tasks/backup.js | 2 +- test/unit/commands/install-spec.js | 27 ++++++++----- test/unit/commands/update-spec.js | 65 ++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 20 deletions(-) diff --git a/lib/commands/install.js b/lib/commands/install.js index f27c9cd8b..bb56dfd3b 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -69,8 +69,8 @@ class InstallCommand extends Command { title: 'Linking latest Ghost and recording versions', task: this.link.bind(this) }, { - title: 'Linking latest Casper', - task: this.casper + title: 'Linking built-in themes', + task: this.defaultThemes }], false) }], { argv: {...argv, version}, @@ -129,12 +129,18 @@ class InstallCommand extends Command { ctx.installPath = path.join(process.cwd(), 'versions', resolvedVersion); // eslint-disable-line require-atomic-updates } - casper() { - // Create a symlink to the theme from the current version - return symlinkSync( - path.join(process.cwd(), 'current', 'content', 'themes', 'casper'), - path.join(process.cwd(), 'content', 'themes', 'casper') - ); + defaultThemes() { + const currentThemesDir = path.join(process.cwd(), 'current', 'content', 'themes'); + const contentThemesDir = path.join(process.cwd(), 'content', 'themes'); + const defaultThemes = fs.readdirSync(currentThemesDir); + for (const theme of defaultThemes) { + if (!fs.existsSync(path.join(contentThemesDir, theme))) { + symlinkSync( + path.join(currentThemesDir, theme), + path.join(contentThemesDir, theme) + ); + } + } } link(ctx) { diff --git a/lib/commands/update.js b/lib/commands/update.js index 991280e18..7eb2aa037 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -1,5 +1,6 @@ const fs = require('fs-extra'); const path = require('path'); +const symlinkSync = require('symlink-or-copy').sync; // Utils const {GhostError} = require('../errors'); @@ -92,6 +93,9 @@ class UpdateCommand extends Command { }, { title: 'Linking latest Ghost and recording versions', task: this.link + }, { + title: 'Installing default themes', + task: this.linkDefaultThemes }, { title: 'Running database migrations', skip: ({rollback}) => rollback, @@ -218,8 +222,6 @@ class UpdateCommand extends Command { } link({instance, installPath, version, rollback}) { - const symlinkSync = require('symlink-or-copy').sync; - fs.removeSync(path.join(process.cwd(), 'current')); symlinkSync(installPath, path.join(process.cwd(), 'current')); @@ -227,6 +229,35 @@ class UpdateCommand extends Command { instance.version = version; instance.nodeVersion = process.versions.node; } + + linkDefaultThemes({instance, rollback}) { + const currentThemesDir = path.join(process.cwd(), 'current', 'content', 'themes'); + const contentThemesDir = path.join(instance.config.get('paths.contentPath'), 'themes'); + // remove any broken symlinks caused by default themes no longer existing in previous version + if (rollback) { + if (fs.existsSync(contentThemesDir)) { + const installedThemes = fs.readdirSync(contentThemesDir); + for (const theme of installedThemes) { + if (!fs.existsSync(path.join(contentThemesDir, theme))) { + fs.rmSync(path.join(contentThemesDir, theme)); + } + } + } + } + + // ensure all default themes (e.g. themes shipped with Ghost) are symlinked to /content/themes directory + if (fs.existsSync(currentThemesDir)) { + const defaultThemes = fs.readdirSync(currentThemesDir); + for (const theme of defaultThemes) { + if (!fs.existsSync(path.join(contentThemesDir, theme))) { + symlinkSync( + path.join(currentThemesDir, theme), + path.join(contentThemesDir, theme) + ); + } + } + } + } } UpdateCommand.description = 'Update a Ghost instance'; diff --git a/lib/tasks/backup.js b/lib/tasks/backup.js index ac2186cf1..8d8a692db 100644 --- a/lib/tasks/backup.js +++ b/lib/tasks/backup.js @@ -74,7 +74,7 @@ module.exports = async function (ui, instance) { const zipPath = path.join(process.cwd(), `backup-${backupSuffix}.zip`); try { - await zip.compress(path.join(instance.dir, 'content/'), zipPath, {glob: `{data/${contentExportFile},data/${membersExportFile},files/**,images/**,media/**,settings/**,themes/**}`, ignore: 'themes/casper'}); + await zip.compress(path.join(instance.dir, 'content/'), zipPath, {glob: `{data/${contentExportFile},data/${membersExportFile},files/**,images/**,media/**,settings/**,themes/**}`, ignore: 'themes/casper,themes/source'}); } catch (err) { throw new ProcessError(err); } diff --git a/test/unit/commands/install-spec.js b/test/unit/commands/install-spec.js index 5c5bf0871..cb3f1a74a 100644 --- a/test/unit/commands/install-spec.js +++ b/test/unit/commands/install-spec.js @@ -207,7 +207,7 @@ describe('Unit: Commands > Install', function () { const runCommandStub = sinon.stub(testInstance, 'runCommand').resolves(); const versionStub = sinon.stub(testInstance, 'version').resolves(); const linkStub = sinon.stub(testInstance, 'link').resolves(); - const casperStub = sinon.stub(testInstance, 'casper').resolves(); + const defaultThemesStub = sinon.stub(testInstance, 'defaultThemes').resolves(); return testInstance.run({version: '1.0.0', setup: false, 'check-empty': true}).then(() => { expect(dirEmptyStub.calledOnce).to.be.true; @@ -216,7 +216,7 @@ describe('Unit: Commands > Install', function () { expect(ensureStructureStub.calledOnce).to.be.true; expect(versionStub.calledOnce).to.be.true; expect(linkStub.calledOnce).to.be.true; - expect(casperStub.calledOnce).to.be.true; + expect(defaultThemesStub.calledOnce).to.be.true; expect(runCommandStub.calledOnce).to.be.true; }); }); @@ -412,20 +412,29 @@ describe('Unit: Commands > Install', function () { }); }); - describe('tasks > casper', function () { - it('links casper version correctly', function () { + describe('tasks > defaultThemes', function () { + it('creates a symlink to all themes shipped with Ghost', function () { const symlinkSyncStub = sinon.stub(); + const readdirSyncStub = sinon.stub().returns(['casper', 'source']); + const existsSyncStub = sinon.stub(); + existsSyncStub.returns(false); const InstallCommand = proxyquire(modulePath, { - 'symlink-or-copy': {sync: symlinkSyncStub} + 'symlink-or-copy': {sync: symlinkSyncStub}, + 'fs-extra': {readdirSync: readdirSyncStub, existsSync: existsSyncStub} }); const testInstance = new InstallCommand({}, {}); - testInstance.casper(); - expect(symlinkSyncStub.calledOnce).to.be.true; + const context = {version: '5.67.0'}; + testInstance.defaultThemes(context); + expect(symlinkSyncStub.callCount).to.equal(2); + expect(symlinkSyncStub.calledWithExactly( + path.join(process.cwd(), 'current', 'content', 'themes', 'casper'), + path.join(process.cwd(), 'content', 'themes', 'casper') + )); expect(symlinkSyncStub.calledWithExactly( - path.join(process.cwd(), 'current/content/themes/casper'), - path.join(process.cwd(), 'content/themes/casper') + path.join(process.cwd(), 'current', 'content', 'themes', 'source'), + path.join(process.cwd(), 'content', 'themes', 'source') )); }); }); diff --git a/test/unit/commands/update-spec.js b/test/unit/commands/update-spec.js index b4e32c431..b0d70926b 100644 --- a/test/unit/commands/update-spec.js +++ b/test/unit/commands/update-spec.js @@ -229,6 +229,7 @@ describe('Unit: Commands > Update', function () { const downloadStub = sinon.stub(cmdInstance, 'downloadAndUpdate').resolves(); const removeOldVersionsStub = sinon.stub(cmdInstance, 'removeOldVersions').resolves(); const linkStub = sinon.stub(cmdInstance, 'link').resolves(); + const linkDefaultThemesStub = sinon.stub(cmdInstance, 'linkDefaultThemes').resolves(); await cmdInstance.run({version: '2.0.1', force: false, zip: '', v1: false, restart: false}); expect(runCommandStub.calledTwice).to.be.true; @@ -246,6 +247,7 @@ describe('Unit: Commands > Update', function () { expect(ui.listr.calledOnce).to.be.true; expect(removeOldVersionsStub.calledOnce).to.be.true; expect(linkStub.calledOnce).to.be.true; + expect(linkDefaultThemesStub.calledOnce).to.be.true; expect(downloadStub.calledOnce).to.be.true; expect(fakeInstance.isRunning.calledOnce).to.be.true; expect(fakeInstance.stop.calledOnce).to.be.true; @@ -277,6 +279,7 @@ describe('Unit: Commands > Update', function () { ghostConfig.get.withArgs('database').returns({ client: 'sqlite3' }); + ghostConfig.get.withArgs('paths.contentPath').returns('/content/themes'); const ui = {log: sinon.stub(), listr: sinon.stub(), run: sinon.stub()}; const system = {getInstance: sinon.stub()}; @@ -468,6 +471,7 @@ describe('Unit: Commands > Update', function () { const cmdInstance = new UpdateCommand(ui, system); const versionStub = sinon.stub(cmdInstance, 'version').resolves(true); const linkStub = sinon.stub(cmdInstance, 'link').resolves(); + sinon.stub(cmdInstance, 'linkDefaultThemes').resolves(); sinon.stub(process, 'cwd').returns(fakeInstance.dir); const downloadStub = sinon.stub(cmdInstance, 'downloadAndUpdate'); const removeOldVersionsStub = sinon.stub(cmdInstance, 'removeOldVersions'); @@ -533,6 +537,7 @@ describe('Unit: Commands > Update', function () { const downloadStub = sinon.stub(cmdInstance, 'downloadAndUpdate'); const removeOldVersionsStub = sinon.stub(cmdInstance, 'removeOldVersions'); const runCommandStub = sinon.stub(cmdInstance, 'runCommand').resolves(); + sinon.stub(cmdInstance, 'linkDefaultThemes').resolves(); await cmdInstance.run({rollback: true, force: false, zip: '', restart: true, v1: true}); const expectedCtx = { @@ -593,7 +598,9 @@ describe('Unit: Commands > Update', function () { fakeInstance.start.resolves(); const cmdInstance = new UpdateCommand(ui, system); const versionStub = sinon.stub(cmdInstance, 'version').resolves(true); + sinon.stub(cmdInstance, 'link').resolves(); + sinon.stub(cmdInstance, 'linkDefaultThemes').resolves(); sinon.stub(process, 'cwd').returns(fakeInstance.dir); sinon.stub(cmdInstance, 'downloadAndUpdate'); sinon.stub(cmdInstance, 'removeOldVersions'); @@ -1069,4 +1076,62 @@ describe('Unit: Commands > Update', function () { expect(instance.nodeVersion).to.equal(process.versions.node); }); }); + + describe('linkDefaultThemes', function () { + const UpdateCommand = require(modulePath); + + it('links all default themes bundled with Ghost', function () { + const command = new UpdateCommand({}, {}); + const envCfg = { + dirs: ['versions/5.62.0', 'versions/5.67.0', 'versions/5.67.0/content/themes/source', 'versions/5.67.0/content/themes/casper', 'content/themes'], + links: [['versions/5.62.0', 'current']] + }; + const env = setupTestFolder(envCfg); + sinon.stub(process, 'cwd').returns(env.dir); + const instance = { + version: '5.62.0', + config: { + get: sinon.stub().withArgs('paths.contentPath').returns(path.join(env.dir, 'content')) + } + }; + const context = { + installPath: path.join(env.dir, 'versions/5.67.0'), + version: '5.67.0', + rollback: false, + instance + }; + + command.link(context); + command.linkDefaultThemes(context); + expect(fs.readlinkSync(path.join(env.dir, 'content', 'themes', 'source'))).to.equal(path.join(env.dir, 'current', 'content', 'themes', 'source')); + expect(fs.readlinkSync(path.join(env.dir, 'content', 'themes', 'casper'))).to.equal(path.join(env.dir, 'current', 'content', 'themes', 'casper')); + }); + + it('removes invalid symlinks when rolling back', function () { + const command = new UpdateCommand({}, {}); + const envCfg = { + dirs: ['versions/5.62.0', 'versions/5.67.0', 'versions/5.62.0/content/themes/casper', 'versions/5.67.0/content/themes/source', 'versions/5.67.0/content/themes/casper', 'content/themes'], + links: [['versions/5.67.0', 'current']] + }; + const env = setupTestFolder(envCfg); + sinon.stub(process, 'cwd').returns(env.dir); + const instance = { + version: '5.67.0', + config: { + get: sinon.stub().withArgs('paths.contentPath').returns(path.join(env.dir, 'content')) + } + }; + const context = { + installPath: path.join(env.dir, 'versions/5.62.0'), + version: '5.62.0', + rollback: true, + instance + }; + + command.link(context); + command.linkDefaultThemes(context); + expect(fs.existsSync(path.join(env.dir, 'content', 'themes', 'source'))).to.equal(false); + expect(fs.readlinkSync(path.join(env.dir, 'content', 'themes', 'casper'))).to.equal(path.join(env.dir, 'current', 'content', 'themes', 'casper')); + }); + }); });