├── .editorconfig ├── .eslintrc.json ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── ---bug-report.md │ ├── ---feature-request.md │ └── --anything-else.md └── workflows │ ├── e2e-test.yml │ ├── local-e2e-test.yml │ ├── stale.yml │ └── unit-test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── bin └── ghost ├── extensions ├── mysql │ ├── index.js │ ├── package.json │ └── test │ │ ├── .eslintrc.json │ │ └── extension-spec.js ├── nginx │ ├── acme.js │ ├── index.js │ ├── migrations.js │ ├── package.json │ ├── templates │ │ ├── nginx-ssl.conf │ │ ├── nginx.conf │ │ └── ssl-params.conf │ ├── test │ │ ├── .eslintrc.json │ │ ├── acme-spec.js │ │ ├── extension-spec.js │ │ ├── fixtures │ │ │ ├── old-ssl-with-le.txt │ │ │ └── ssl-without-le.txt │ │ └── migrations-spec.js │ └── utils.js └── systemd │ ├── doctor.js │ ├── get-uid.js │ ├── ghost.service.template │ ├── index.js │ ├── package.json │ ├── systemd.js │ └── test │ ├── .eslintrc.json │ ├── doctor-spec.js │ ├── extension-spec.js │ ├── get-uid-spec.js │ └── systemd-spec.js ├── lib ├── bootstrap.js ├── command.js ├── commands │ ├── backup.js │ ├── buster.js │ ├── check-update.js │ ├── config.js │ ├── doctor │ │ ├── checks │ │ │ ├── binary-deps.js │ │ │ ├── check-directory.js │ │ │ ├── check-memory.js │ │ │ ├── check-permissions.js │ │ │ ├── content-folder.js │ │ │ ├── file-permissions.js │ │ │ ├── folder-permissions.js │ │ │ ├── free-space.js │ │ │ ├── index.js │ │ │ ├── install-folder-permissions.js │ │ │ ├── logged-in-ghost-user.js │ │ │ ├── logged-in-user-owner.js │ │ │ ├── logged-in-user.js │ │ │ ├── mysql.js │ │ │ ├── node-version.js │ │ │ ├── system-stack.js │ │ │ └── validate-config.js │ │ └── index.js │ ├── export.js │ ├── import.js │ ├── install.js │ ├── log.js │ ├── ls.js │ ├── migrate.js │ ├── restart.js │ ├── run.js │ ├── setup.js │ ├── start.js │ ├── stop.js │ ├── uninstall.js │ ├── update.js │ └── version.js ├── errors.js ├── extension.js ├── index.js ├── instance.js ├── migrations.js ├── process-manager.js ├── system.js ├── tasks │ ├── backup.js │ ├── configure │ │ ├── get-prompts.js │ │ ├── index.js │ │ ├── options.js │ │ └── parse-options.js │ ├── ensure-structure.js │ ├── import │ │ ├── api.js │ │ ├── index.js │ │ ├── parse-export.js │ │ └── tasks.js │ ├── linux.js │ ├── major-update │ │ ├── data.js │ │ ├── index.js │ │ └── ui.js │ ├── migrator.js │ ├── release-notes.js │ └── yarn-install.js ├── ui │ ├── index.js │ ├── pretty-stream.js │ └── renderer.js └── utils │ ├── check-root-user.js │ ├── check-valid-install.js │ ├── config.js │ ├── deprecation-checks.js │ ├── dir-is-empty.js │ ├── find-extensions.js │ ├── find-valid-install.js │ ├── get-instance.js │ ├── get-proxy-agent.js │ ├── local-process.js │ ├── needed-migrations.js │ ├── port-polling.js │ ├── pre-checks.js │ ├── url.js │ ├── use-ghost-user.js │ ├── version.js │ └── yarn.js ├── package.json ├── renovate.json ├── test ├── .eslintrc.json ├── fixtures │ ├── TestExtension │ │ └── index.js │ ├── classes │ │ ├── test-invalid-command.js │ │ ├── test-invalid-process.js │ │ ├── test-process-missing-methods.js │ │ ├── test-process-wont-run.js │ │ ├── test-valid-command.js │ │ └── test-valid-process.js │ ├── ghost-2.0-rc.2.zip │ ├── ghost-2.0.1.zip │ ├── ghost-2.0.zip │ ├── ghost-invalid-cli.zip │ ├── ghost-invalid-node.zip │ ├── ghostlts.zip │ ├── ghostold.zip │ ├── ghostrelease.zip │ ├── nopkg.zip │ └── notghost.zip ├── unit │ ├── bootstrap-spec.js │ ├── command-spec.js │ ├── commands │ │ ├── buster-spec.js │ │ ├── check-update-spec.js │ │ ├── config-spec.js │ │ ├── doctor │ │ │ ├── checks │ │ │ │ ├── binary-deps-spec.js │ │ │ │ ├── check-directory-spec.js │ │ │ │ ├── check-memory-spec.js │ │ │ │ ├── check-permissions-spec.js │ │ │ │ ├── content-folder-spec.js │ │ │ │ ├── file-permissions-spec.js │ │ │ │ ├── folder-permissions-spec.js │ │ │ │ ├── free-space-spec.js │ │ │ │ ├── install-folder-permissions-spec.js │ │ │ │ ├── logged-in-ghost-user-spec.js │ │ │ │ ├── logged-in-user-owner-spec.js │ │ │ │ ├── logged-in-user-spec.js │ │ │ │ ├── mysql-spec.js │ │ │ │ ├── node-version-spec.js │ │ │ │ ├── system-stack-spec.js │ │ │ │ └── validate-config-spec.js │ │ │ └── command-spec.js │ │ ├── export-spec.js │ │ ├── import-spec.js │ │ ├── install-spec.js │ │ ├── log-spec.js │ │ ├── ls-spec.js │ │ ├── migrate-spec.js │ │ ├── restart-spec.js │ │ ├── run-spec.js │ │ ├── setup-spec.js │ │ ├── start-spec.js │ │ ├── stop-spec.js │ │ ├── uninstall-spec.js │ │ ├── update-spec.js │ │ └── version-spec.js │ ├── errors-spec.js │ ├── extension-spec.js │ ├── index-spec.js │ ├── instance-spec.js │ ├── migrations-spec.js │ ├── process-manager-spec.js │ ├── system-spec.js │ ├── tasks │ │ ├── configure │ │ │ ├── get-prompts-spec.js │ │ │ ├── index-spec.js │ │ │ ├── options-spec.js │ │ │ └── parse-options-spec.js │ │ ├── ensure-structure-spec.js │ │ ├── import │ │ │ ├── api-spec.js │ │ │ ├── fixtures │ │ │ │ ├── 0.11.x.json │ │ │ │ ├── 1.x.json │ │ │ │ ├── 2.x.json │ │ │ │ ├── 3.x.json │ │ │ │ ├── 4.x.json │ │ │ │ └── 5.x.json │ │ │ ├── parse-export-spec.js │ │ │ └── tasks-spec.js │ │ ├── linux-spec.js │ │ ├── major-update │ │ │ ├── data-spec.js │ │ │ └── ui-spec.js │ │ ├── migrator-spec.js │ │ ├── release-notes-spec.js │ │ └── yarn-install-spec.js │ ├── ui │ │ ├── index-spec.js │ │ ├── pretty-stream-spec.js │ │ └── renderer-spec.js │ └── utils │ │ ├── check-root-user-spec.js │ │ ├── check-valid-install-spec.js │ │ ├── config-spec.js │ │ ├── dir-is-empty.js │ │ ├── find-extensions-spec.js │ │ ├── get-instance-spec.js │ │ ├── local-process-spec.js │ │ ├── needed-migrations-spec.js │ │ ├── port-polling-spec.js │ │ ├── pre-checks-spec.js │ │ ├── url.js │ │ ├── use-ghost-user-spec.js │ │ ├── version-spec.js │ │ └── yarn-spec.js └── utils │ ├── config-stub.js │ ├── stream.js │ └── test-folder.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | 16 | [*.{yml,json}] 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["ghost"], 3 | "extends": [ 4 | "plugin:ghost/node" 5 | ], 6 | "parserOptions": { 7 | "ecmaVersion": "2018" 8 | }, 9 | "rules": { 10 | "no-console": ["off"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Ghost-CLI 2 | 3 | Welcome to the Ghost-CLI project, and thank you for wanting to get started contributing! 4 | 5 | To setup Ghost-CLI for development, please read through the developer setup in the [readme](../README.md). 6 | 7 | Read through the list of [open issues](https://github.com/TryGhost/Ghost-CLI/issues), and if you find one that you want to work on, please comment on it so that others will know it's being worked on 😉 8 | 9 | Once you've implemented the feature or fixed the issue, please make sure: 10 | 11 | - it passes tests (`yarn test`) 12 | - all commits are squashed into one or two commits 13 | - the commit message follows [this format](https://github.com/conventional-changelog/standard-version#commit-message-convention-at-a-glance) 14 | 15 | Then submit a PR and one of the core team will review it! 16 | 17 | If you have any questions, feel free to drop by our [forum](https://forum.ghost.org)! We'd be happy to help 😄 18 | 19 | #### Note: If you wish to implement a new feature, it would be wise to open an issue about it beforehand. That way the Core Team can make comments and ensure that the feature is in the best interests and direction of the CLI. 20 | 21 | ## Contributor License Agreement 22 | 23 | By contributing your code to Ghost you grant the Ghost Foundation a non-exclusive, irrevocable, worldwide, royalty-free, sublicenseable, transferable license under all of Your relevant intellectual property rights (including copyright, patent, and any other rights), to use, copy, prepare derivative works of, distribute and publicly perform and display the Contributions on any licensing terms, including without limitation: (a) open source licenses like the MIT license; and (b) binary, proprietary, or commercial licenses. Except for the licenses granted herein, You reserve all right, title, and interest in and to the Contribution. 24 | 25 | You confirm that you are able to grant us these rights. You represent that You are legally entitled to grant the above license. If Your employer has rights to intellectual property that You create, You represent that You have received permission to make the Contributions on behalf of that employer, or that Your employer has waived such rights for the Contributions. 26 | 27 | You represent that the Contributions are Your original works of authorship, and to Your knowledge, no other person claims, or has the right to claim, any right in any invention or patent related to the Contributions. You also represent that You are not legally obligated, whether by entering into an agreement or otherwise, in any way that conflicts with the terms of this license. 28 | 29 | The Ghost Foundation acknowledges that, except as explicitly described in this Agreement, any Contribution which you provide is on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Report reproducible software issues so we can improve 4 | 5 | --- 6 | 7 | Welcome to Ghost-CLI's GitHub repo! 👋🎉 8 | 9 | Do you need help or have a question? Please come chat in our forum: https://forum.ghost.org 👫. 10 | 11 | Docs: https://ghost.org/docs/ 📖. 12 | 13 | Please be aware that the team behind the Ghost CLI only supports the recommended stack: https://github.com/TryGhost/Ghost-cli#recommended-stack. 14 | 15 | ### Summary 16 | 17 | Search GitHub for existing issues & check the docs: https://ghost.org/docs/faq/. If you're still stuck, please provide a quick summary of the problem, steps to reproduce, and full tech details including logs. 18 | 19 | ### Steps to Reproduce 20 | 21 | 1. This is the first step 22 | 2. This is the second step, etc. 23 | 24 | Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead? 25 | 26 | ### Log file 27 | 28 | All errors end with "Additional log info available in: [filepath]". 29 | 30 | Use the command `cat [filepath]` to read the log, then copy & paste the contents here: 31 | 32 | ``` 33 | Paste log file here 34 | ``` 35 | 36 | ### Technical details 37 | 38 | This is automatically output by Ghost-CLI if an error occurs, please copy & paste: 39 | 40 | * OS: 41 | * Node Version: 42 | * Ghost-CLI Version: 43 | * Environment: 44 | * Command: 45 | 46 | ## Bug submission checklist 47 | 48 | Please fill out this checklist to acknowledge that you followed the requirements to submit a bug report. 49 | 50 | - [ ] Tried to find help in the forum & docs 51 | - [ ] Checked for existing issues 52 | - [ ] Attached log file 53 | - [ ] Provided technical details incl. operating system 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature Requests" 3 | about: Requests for a new feature (must have a clear and detailed use-case) 4 | 5 | --- 6 | 7 | ### Summary 8 | 9 | Describe the feature and make a clear, detailed case for adding it. 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/--anything-else.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4A1Anything else" 3 | about: "For help, support, questions & ideas - please use https://forum.ghost.org 4 | \U0001F46B" 5 | 6 | --- 7 | 8 | We primarily use GitHub as an issue tracker. 9 | 10 | For usage and support questions, or ideas & feature requests please check out [our forum](https://forum.ghost.org). 11 | 12 | Alternatively, check out these resources below. Thanks! 😁. 13 | 14 | - [Ghost-CLI docs](https://ghost.org/docs/ghost-cli/) 15 | - [Forum Support](https://forum.ghost.org/c/help) 16 | - [Ideas](https://forum.ghost.org/c/Ideas) 17 | - [Contributing Guide](https://ghost.org/docs/contributing/) 18 | - [Self-hoster Docs](https://ghost.org/docs/install/ubuntu/) 19 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | e2e: 10 | runs-on: ${{ matrix.os }} 11 | name: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | os: [ubuntu-20.04] 16 | password: [root] 17 | node: [20.x] 18 | steps: 19 | - name: Start nginx 20 | run: sudo service nginx start 21 | - name: Updating hosts file 22 | run: 'echo -e "127.0.0.1 cli-testing.ghost.org\n" | sudo tee -a /etc/hosts' 23 | - uses: actions/checkout@v4 24 | with: 25 | path: "cli" 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node }} 29 | cache: yarn 30 | cache-dependency-path: cli/yarn.lock 31 | env: 32 | FORCE_COLOR: 0 33 | - name: Start MySQL service 34 | run: sudo systemctl start mysql.service 35 | - name: Prepare CLI 36 | run: | 37 | cd cli 38 | yarn install --frozen-lockfile 39 | sudo yarn link --link-folder /usr/bin/ 40 | - name: Setting up Ghost instance 41 | run: | 42 | ghost install \ 43 | -d ghost \ 44 | --auto \ 45 | --no-prompt \ 46 | --url http://cli-testing.ghost.org \ 47 | --db mysql \ 48 | --dbhost 127.0.0.1 \ 49 | --dbuser root \ 50 | --dbpass ${{ matrix.password }} \ 51 | --dbname ghost-mysql 52 | - name: Verifying Installation 53 | run: curl http://cli-testing.ghost.org | grep ghost 54 | -------------------------------------------------------------------------------- /.github/workflows/local-e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: Local E2E Tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | local-e2e: 10 | runs-on: ${{ matrix.os }} 11 | name: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | os: [ubuntu-latest, macos-latest] 16 | node: [20.x] 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | path: "cli" 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node }} 24 | - name: Prepare CLI 25 | run: yarn --cwd cli install --frozen-lockfile 26 | - name: Use latest global yarn version 27 | run: | 28 | cd ~ 29 | yarn set version berry 30 | - name: Setting up Ghost instance 31 | run: node ./cli/bin/ghost install local -d ghost 32 | - name: Verifying Installation 33 | run: sleep 2 && curl -f http://localhost:2368 | grep ghost 34 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '0 15 * * *' 5 | jobs: 6 | stale: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/stale@v6 10 | with: 11 | stale-issue-message: 'Our bot has automatically marked this issue as stale because there has not been any activity here in some time. The issue will be closed soon if there are no further updates, however we ask that you do not post comments to keep the issue open if you are not actively working on a PR. We keep the issue list minimal so we can keep focus on the most pressing issues. Closed issues can always be reopened if a new contributor is found. Thank you for understanding 🙂' 12 | stale-pr-message: 'Our bot has automatically marked this PR as stale because there has not been any activity here in some time. If we’ve failed to review your PR & you’re still interested in working on it, please let us know. Otherwise this PR will be closed shortly, but can always be reopened later. Thank you for understanding 🙂' 13 | exempt-issue-labels: 'feature,pinned' 14 | exempt-pr-labels: 'feature,pinned' 15 | days-before-stale: 120 16 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node: [18.x, 20.x, 22.x] 13 | env: 14 | FORCE_COLOR: 1 15 | name: Node ${{ matrix.node }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node }} 21 | # temporary, remove this once the cached node 22.x version is updated with lts 22 | check-latest: true 23 | cache: yarn 24 | env: 25 | FORCE_COLOR: 0 26 | - run: yarn 27 | - run: yarn lint 28 | - run: yarn test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | package-lock.json 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | .nyc_output 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | node_modules 30 | 31 | # Optional npm cache directory 32 | .npm 33 | 34 | # Optional REPL history 35 | .node_repl_history 36 | 37 | # Editors 38 | .idea/* 39 | *.iml 40 | *.sublime-* 41 | .vscode/* 42 | 43 | # vim-related 44 | [._]*.s[a-w][a-z] 45 | [._]s[a-w][a-z] 46 | *.un~ 47 | Session.vim 48 | .netrwhist 49 | .vimrc 50 | *~ 51 | 52 | # Temp folder - useful for manual testing! 53 | tmp 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2025 Ghost Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Vulnerabilities 2 | 3 | Potential security vulnerabilities can be reported directly us at `security@ghost.org`. The Ghost Security Team communicates privately and works in a secured, isolated repository for tracking, testing, and resolving security-related issues. 4 | 5 | The full, up-to-date details of our security policy and procedure can always be found in our documentation: 6 | 7 | https://ghost.org/docs/security/ 8 | 9 | Please refer to this before emailing us. Thanks for helping make Ghost safe for everyone 🙏. 10 | -------------------------------------------------------------------------------- /bin/ghost: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | // provide a title to the process 5 | process.title = 'ghost'; 6 | 7 | const argv = process.argv.slice(2); 8 | 9 | if (argv.length === 0) { 10 | console.error('No command specified. Run `ghost help` for usage'); 11 | process.exit(1); 12 | } 13 | 14 | const yargs = require('yargs'); 15 | const bootstrap = require('../lib/bootstrap'); 16 | bootstrap.run(argv, yargs); 17 | -------------------------------------------------------------------------------- /extensions/mysql/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghost-cli-mysql", 3 | "version": "0.0.0", 4 | "description": "MySQL configuration handling for Ghost-CLI", 5 | "keywords": [ 6 | "ghost-cli-extension" 7 | ], 8 | "main": "index.js", 9 | "ghost-cli": { 10 | "before": "ghost-cli-linux" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /extensions/mysql/test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "prefer-arrow-callback": "off", 7 | "func-names": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /extensions/nginx/acme.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs-extra'); 3 | const os = require('os'); 4 | const got = require('got'); 5 | const path = require('path'); 6 | const download = require('download'); 7 | 8 | const {errors: {CliError, ProcessError, SystemError}} = require('../../lib'); 9 | const {errorWrapper} = require('./utils'); 10 | 11 | const nginxProgramName = process.env.NGINX_PROGRAM_NAME || 'nginx'; 12 | 13 | function isInstalled() { 14 | return fs.existsSync('/etc/letsencrypt/acme.sh'); 15 | } 16 | 17 | async function install(ui) { 18 | if (isInstalled()) { 19 | await ui.sudo('/etc/letsencrypt/acme.sh --upgrade --home /etc/letsencrypt'); 20 | return; 21 | } 22 | 23 | const acmeTmpDir = path.join(os.tmpdir(), 'acme.sh'); 24 | const acmeApiUrl = 'https://api.github.com/repos/Neilpang/acme.sh/releases/latest'; 25 | 26 | ui.logVerbose('ssl: creating /etc/letsencrypt directory', 'green'); 27 | 28 | // acme.sh creates the directory without global read permissions, so we need to make 29 | // sure it has global read permissions first 30 | await ui.sudo('mkdir -p /etc/letsencrypt'); 31 | ui.logVerbose('ssl: downloading acme.sh to temporary directory', 'green'); 32 | await fs.emptyDir(acmeTmpDir); 33 | 34 | let downloadURL; 35 | 36 | try { 37 | downloadURL = JSON.parse((await got(acmeApiUrl)).body).tarball_url; 38 | } catch (error) { 39 | throw new CliError({ 40 | message: 'Unable to fetch download URL from GitHub', 41 | err: error 42 | }); 43 | } 44 | 45 | await download(downloadURL, acmeTmpDir, {extract: true}); 46 | // The archive contains a single folder with the structure 47 | // `{user}-{repo}-{commit}`, but we don't know what commit is 48 | // from the API call. Since the dir is empty (we cleared it), 49 | // the only thing in acmeTmpDir will be the extracted zip. 50 | const acmeCodeDir = path.resolve(acmeTmpDir, fs.readdirSync(acmeTmpDir)[0]); 51 | 52 | ui.logVerbose('ssl: installing acme.sh components', 'green'); 53 | 54 | // Installs acme.sh into /etc/letsencrypt 55 | await ui.sudo('./acme.sh --install --home /etc/letsencrypt', {cwd: acmeCodeDir}); 56 | } 57 | 58 | async function generateCert(ui, domain, webroot, email, staging) { 59 | const parts = [ 60 | '/etc/letsencrypt/acme.sh', 61 | '--issue', 62 | '--home /etc/letsencrypt', 63 | '--server letsencrypt', 64 | `--domain ${domain}`, 65 | `--webroot ${webroot}`, 66 | `--reloadcmd "${nginxProgramName} -s reload"`, 67 | `--accountemail ${email}`, 68 | '--keylength 2048' 69 | ]; 70 | 71 | if (staging) { 72 | parts.push('--staging'); 73 | } 74 | 75 | const cmd = parts.join(' '); 76 | 77 | try { 78 | await ui.sudo(cmd); 79 | } catch (error) { 80 | if (error.code === 2) { 81 | // error code 2 is given if a cert doesn't need to be renewed 82 | return; 83 | } 84 | 85 | if (error.stderr.match(/Verify error:(Fetching|Invalid Response)/)) { 86 | // Domain verification failed 87 | throw new SystemError('Your domain name is not pointing to the correct IP address of your server, check your DNS has propagated and run `ghost setup ssl` again'); 88 | } 89 | 90 | // It's not an error we expect might happen, throw a ProcessError instead. 91 | throw new ProcessError(error); 92 | } 93 | } 94 | 95 | async function remove(domain, ui, acmeHome) { 96 | acmeHome = acmeHome || '/etc/letsencrypt'; 97 | 98 | const cmd = `${acmeHome}/acme.sh --remove --home ${acmeHome} --domain ${domain}`; 99 | 100 | try { 101 | await ui.sudo(cmd); 102 | } catch (error) { 103 | throw new ProcessError(error); 104 | } 105 | } 106 | 107 | module.exports = { 108 | install: errorWrapper(install), 109 | isInstalled: isInstalled, 110 | generate: generateCert, 111 | remove: remove 112 | }; 113 | -------------------------------------------------------------------------------- /extensions/nginx/migrations.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs-extra'); 3 | const os = require('os'); 4 | const url = require('url'); 5 | const path = require('path'); 6 | 7 | const cli = require('../../lib'); 8 | 9 | function migrateSSL(ctx, migrateTask) { 10 | const replace = require('replace-in-file'); 11 | const acme = require('./acme'); 12 | 13 | const parsedUrl = url.parse(ctx.instance.config.get('url')); 14 | const confFile = path.join(ctx.instance.dir, 'system', 'files', `${parsedUrl.hostname}-ssl.conf`); 15 | const rootPath = path.resolve(ctx.instance.dir, 'system', 'nginx-root'); 16 | 17 | // Case to skip 1: SSL config for nginx does not exist 18 | if (!fs.existsSync(confFile)) { 19 | return migrateTask.skip('SSL config has not been set up for this domain'); 20 | } 21 | 22 | const originalAcmePath = path.join(os.homedir(), '.acme.sh'); 23 | const originalCertFolder = path.join(originalAcmePath, parsedUrl.hostname); 24 | 25 | // Case to skip 2: SSL cert doesn't exist in the original location for this domain 26 | if (!fs.existsSync(originalCertFolder)) { 27 | return migrateTask.skip('SSL cert does not exist for this domain'); 28 | } 29 | 30 | const confFileContents = fs.readFileSync(confFile, {encoding: 'utf8'}); 31 | const certCheck = new RegExp(`ssl_certificate ${originalCertFolder}/fullchain.cer;`); 32 | 33 | // Case to skip 3: SSL conf does not contain a cert config using the old LE cert 34 | if (!certCheck.test(confFileContents)) { 35 | return migrateTask.skip('LetsEncrypt SSL cert is not being used for this domain'); 36 | } 37 | 38 | // 1. parse ~/.acme.sh/account.conf to get the email 39 | const accountConf = fs.readFileSync(path.join(originalAcmePath, 'account.conf'), {encoding: 'utf8'}); 40 | const parsed = accountConf.match(/ACCOUNT_EMAIL='(.*)'\n/); 41 | 42 | if (!parsed) { 43 | throw new cli.errors.SystemError('Unable to parse letsencrypt account email'); 44 | } 45 | 46 | return this.ui.listr([{ 47 | // 2. install acme.sh in /etc/letsencrypt if that hasn't been done already 48 | title: 'Installing acme.sh in new location', 49 | task: (ctx, task) => acme.install(this.ui, task) 50 | }, { 51 | // 3. run install cert for new acme.sh instance 52 | title: 'Regenerating SSL certificate in new location', 53 | task: () => acme.generate(this.ui, parsedUrl.hostname, rootPath, parsed[1], false) 54 | }, { 55 | // 4. Update cert locations in nginx-ssl.conf 56 | title: 'Updating nginx config', 57 | task: () => { 58 | const acmeFolder = path.join('/etc/letsencrypt', parsedUrl.hostname); 59 | 60 | return replace({ 61 | files: confFile, 62 | from: [ 63 | // Ensure here that we ONLY replace instances of the LetsEncrypt cert in the file, 64 | // that way we don't overwrite the cert config of other certs. 65 | certCheck, 66 | new RegExp(`ssl_certificate_key ${originalCertFolder}/${parsedUrl.hostname}.key;`) 67 | ], 68 | to: [ 69 | `ssl_certificate ${path.join(acmeFolder, 'fullchain.cer')};`, 70 | `ssl_certificate_key ${path.join(acmeFolder, `${parsedUrl.hostname}.key`)};` 71 | ] 72 | }); 73 | } 74 | }, { 75 | title: 'Restarting Nginx', 76 | task: () => this.restartNginx() 77 | }, { 78 | // 5. run acme.sh --remove -d domain in old acme.sh directory to remove the old cert from renewal 79 | title: 'Disabling renewal for old certificate', 80 | task: () => acme.remove(parsedUrl.hostname, this.ui, originalAcmePath) 81 | }], false); 82 | } 83 | 84 | module.exports = { 85 | migrateSSL: migrateSSL 86 | }; 87 | -------------------------------------------------------------------------------- /extensions/nginx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghost-cli-nginx", 3 | "version": "0.0.0", 4 | "description": "nginx integration support for Ghost-CLI", 5 | "main": "index.js", 6 | "keywords": [ 7 | "ghost-cli-extension" 8 | ], 9 | "ghost-cli": { 10 | "name": "nginx", 11 | "after": "ghost-cli-user", 12 | 13 | "options": { 14 | "setup": { 15 | "sslemail": { 16 | "description": "Email to use when setting up SSL", 17 | "type": "string", 18 | "group": "Nginx Options:" 19 | }, 20 | 21 | "sslstaging": { 22 | "description": "Use the LetsEncrypt staging server (useful for testing purposes)", 23 | "type": "boolean", 24 | "group": "Nginx Options:" 25 | } 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /extensions/nginx/templates/nginx-ssl.conf: -------------------------------------------------------------------------------- 1 | map $status $header_content_type_options { 2 | 204 ""; 3 | default "nosniff"; 4 | } 5 | 6 | server { 7 | listen 443 ssl http2; 8 | listen [::]:443 ssl http2; 9 | 10 | server_name <%= url %>; 11 | root <%= webroot %>; # Used for acme.sh SSL verification (https://acme.sh) 12 | 13 | ssl_certificate <%= fullchain %>; 14 | ssl_certificate_key <%= privkey %>; 15 | include <%= sslparams %>; 16 | 17 | location <%= location %> { 18 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 19 | proxy_set_header X-Forwarded-Proto $scheme; 20 | proxy_set_header X-Real-IP $remote_addr; 21 | proxy_set_header Host $http_host; 22 | proxy_pass http://127.0.0.1:<%= port %>; 23 | <% if (location !== '/') { %>proxy_redirect off;<% } %> 24 | add_header X-Content-Type-Options $header_content_type_options; 25 | } 26 | 27 | location ~ /.well-known { 28 | allow all; 29 | } 30 | 31 | client_max_body_size 1g; 32 | } 33 | -------------------------------------------------------------------------------- /extensions/nginx/templates/nginx.conf: -------------------------------------------------------------------------------- 1 | map $status $header_content_type_options { 2 | 204 ""; 3 | default "nosniff"; 4 | } 5 | 6 | server { 7 | listen 80; 8 | listen [::]:80; 9 | 10 | server_name <%= url %>; 11 | root <%= webroot %>; # Used for acme.sh SSL verification (https://acme.sh) 12 | 13 | location <%= location %> { 14 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 15 | proxy_set_header X-Forwarded-Proto $scheme; 16 | proxy_set_header X-Real-IP $remote_addr; 17 | proxy_set_header Host $http_host; 18 | proxy_pass http://127.0.0.1:<%= port %>; 19 | <% if (location !== '/') { %>proxy_redirect off;<% } %> 20 | add_header X-Content-Type-Options $header_content_type_options; 21 | } 22 | 23 | location ~ /.well-known { 24 | allow all; 25 | } 26 | 27 | client_max_body_size 50m; 28 | } 29 | -------------------------------------------------------------------------------- /extensions/nginx/templates/ssl-params.conf: -------------------------------------------------------------------------------- 1 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 2 | ssl_prefer_server_ciphers on; 3 | ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS'; 4 | ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0 5 | ssl_session_cache shared:SSL:10m; 6 | ssl_session_tickets off; # Requires nginx >= 1.5.9 7 | ssl_stapling on; # Requires nginx >= 1.3.7 8 | ssl_stapling_verify on; # Requires nginx => 1.3.7 9 | resolver 8.8.8.8 8.8.4.4 valid=300s; 10 | resolver_timeout 5s; 11 | add_header Strict-Transport-Security 'max-age=63072000; includeSubDomains; preload'; 12 | add_header X-Frame-Options SAMEORIGIN; 13 | ssl_dhparam <%= dhparam %>; 14 | -------------------------------------------------------------------------------- /extensions/nginx/test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "prefer-arrow-callback": "off", 7 | "func-names": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /extensions/nginx/test/fixtures/old-ssl-with-le.txt: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2; 3 | listen [::]:443 ssl http2; 4 | 5 | server_name ghost.org; 6 | root /var/www/ghost/system/nginx-root; 7 | 8 | ssl_certificate /home/ghost/.acme.sh/ghost.org/fullchain.cer; 9 | ssl_certificate_key /home/ghost/.acme.sh/ghost.org/ghost.org.key; 10 | include /var/www/ghost/system/files/ssl-params.conf; 11 | 12 | location / { 13 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 14 | proxy_set_header X-Forwarded-Proto $scheme; 15 | proxy_set_header X-Real-IP $remote_addr; 16 | proxy_set_header Host $http_host; 17 | proxy_pass http://127.0.0.1:2368; 18 | } 19 | 20 | location ~ /.well-known { 21 | allow all; 22 | } 23 | 24 | client_max_body_size 50m; 25 | } 26 | -------------------------------------------------------------------------------- /extensions/nginx/test/fixtures/ssl-without-le.txt: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2; 3 | listen [::]:443 ssl http2; 4 | 5 | server_name ghost.org; 6 | root /var/www/ghost/system/nginx-root; 7 | 8 | ssl_certificate /etc/ssl/comodo/ghost.org.cer; 9 | ssl_certificate_key /etc/ssl/comodo/ghost.org.key; 10 | include /var/www/ghost/system/files/ssl-params.conf; 11 | 12 | location / { 13 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 14 | proxy_set_header X-Forwarded-Proto $scheme; 15 | proxy_set_header X-Real-IP $remote_addr; 16 | proxy_set_header Host $http_host; 17 | proxy_pass http://127.0.0.1:2368; 18 | } 19 | 20 | location ~ /.well-known { 21 | allow all; 22 | } 23 | 24 | client_max_body_size 50m; 25 | } 26 | -------------------------------------------------------------------------------- /extensions/nginx/utils.js: -------------------------------------------------------------------------------- 1 | const {errors} = require('../../lib'); 2 | const {ProcessError, CliError} = errors; 3 | 4 | function errorWrapper(fn) { 5 | return async (...args) => { 6 | try { 7 | await fn(...args); 8 | } catch (error) { 9 | if (error instanceof CliError) { 10 | throw error; 11 | } 12 | 13 | throw new ProcessError(error); 14 | } 15 | }; 16 | } 17 | 18 | module.exports = {errorWrapper}; 19 | -------------------------------------------------------------------------------- /extensions/systemd/doctor.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const get = require('lodash/get'); 3 | const path = require('path'); 4 | const ini = require('ini'); 5 | const chalk = require('chalk'); 6 | const semver = require('semver'); 7 | const execa = require('execa'); 8 | const {errors} = require('../../lib'); 9 | 10 | const {SystemError} = errors; 11 | 12 | const systemdEnabled = 13 | ({instance}) => instance.config.get('process', 'local') === 'systemd'; 14 | 15 | const unitCheckTitle = 'Checking systemd unit file'; 16 | const nodeCheckTitle = 'Checking systemd node version'; 17 | 18 | async function checkUnitFile(ctx) { 19 | const unitFilePath = `/lib/systemd/system/ghost_${ctx.instance.name}.service`; 20 | ctx.systemd = {unitFilePath}; 21 | 22 | try { 23 | const contents = await fs.readFile(unitFilePath); 24 | ctx.systemd.unit = ini.parse(contents.toString('utf8').trim()); 25 | } catch (error) { 26 | throw new SystemError({ 27 | message: 'Unable to load or parse systemd unit file', 28 | err: error 29 | }); 30 | } 31 | } 32 | 33 | async function checkNodeVersion({instance, systemd, ui}, task) { 34 | const errBlock = { 35 | message: 'Unable to determine node version in use by systemd', 36 | help: `Ensure 'ExecStart' exists in ${chalk.cyan(systemd.unitFilePath)} and uses a valid Node version` 37 | }; 38 | 39 | const execStart = get(systemd, 'unit.Service.ExecStart', null); 40 | if (!execStart) { 41 | throw new SystemError(errBlock); 42 | } 43 | 44 | const [nodePath] = execStart.split(' '); 45 | let version; 46 | 47 | try { 48 | const stdout = await execa.stdout(nodePath, ['--version']); 49 | version = semver.valid(stdout.trim()); 50 | } catch (_) { 51 | throw new SystemError(errBlock); 52 | } 53 | 54 | if (!version) { 55 | throw new SystemError(errBlock); 56 | } 57 | 58 | task.title = `${nodeCheckTitle} - found v${version}`; 59 | 60 | if (!semver.eq(version, process.versions.node)) { 61 | ui.log( 62 | `Warning: Ghost is running with node v${version}.\n` + 63 | `Your current node version is v${process.versions.node}.`, 64 | 'yellow' 65 | ); 66 | } 67 | 68 | let nodeRange; 69 | 70 | try { 71 | const packagePath = path.join(instance.dir, 'current/package.json'); 72 | const ghostPkg = await fs.readJson(packagePath); 73 | nodeRange = get(ghostPkg, 'engines.node', null); 74 | } catch (_) { 75 | return; 76 | } 77 | 78 | if (!nodeRange) { 79 | return; 80 | } 81 | 82 | if (!semver.satisfies(version, nodeRange)) { 83 | throw new SystemError({ 84 | message: `Ghost v${instance.version} is not compatible with Node v${version}`, 85 | help: `Check the version of Node configured in ${chalk.cyan(systemd.unitFilePath)} and update it to a compatible version` 86 | }); 87 | } 88 | } 89 | 90 | module.exports = [{ 91 | title: unitCheckTitle, 92 | task: checkUnitFile, 93 | enabled: systemdEnabled, 94 | category: ['start'] 95 | }, { 96 | title: nodeCheckTitle, 97 | task: checkNodeVersion, 98 | enabled: systemdEnabled, 99 | category: ['start'] 100 | }]; 101 | 102 | // exports for unit testing 103 | module.exports.checkUnitFile = checkUnitFile; 104 | module.exports.checkNodeVersion = checkNodeVersion; 105 | -------------------------------------------------------------------------------- /extensions/systemd/get-uid.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const execa = require('execa'); 6 | 7 | /** 8 | * Helper function used by both the extension setup method 9 | * and the systemd process manager. This checks if the 10 | * linux ghost user has been set up. If not, the function returns null, 11 | * but if so, it returns the user id of the ghost user 12 | */ 13 | module.exports = function getUid(dir) { 14 | try { 15 | const uid = execa.shellSync('id -u ghost').stdout; 16 | const stat = fs.lstatSync(path.join(dir, 'content')); 17 | 18 | if (stat.uid.toString() !== uid) { 19 | // Ghost user is not the owner of this folder, return null 20 | return null; 21 | } 22 | 23 | return uid; 24 | } catch (e) { 25 | // CASE: the ghost user doesn't exist, hence can't be used 26 | // We just return null and not doing anything with the error, 27 | // as it would either mean, that the user doesn't exist (this 28 | // is exactly what we want to know), or the command is not by the OS 29 | return null; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /extensions/systemd/ghost.service.template: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Ghost systemd service for blog: <%= name %> 3 | Documentation=https://ghost.org/docs/ 4 | 5 | [Service] 6 | Type=simple 7 | WorkingDirectory=<%= dir %> 8 | User=<%= user %> 9 | Environment="NODE_ENV=<%= environment %>" 10 | ExecStart=<%= ghost_exec_path %> run 11 | Restart=always 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /extensions/systemd/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const path = require('path'); 3 | const template = require('lodash/template'); 4 | 5 | const getUid = require('./get-uid'); 6 | const {Extension, errors} = require('../../lib'); 7 | 8 | const {ProcessError, SystemError} = errors; 9 | 10 | class SystemdExtension extends Extension { 11 | doctor() { 12 | const checks = require('./doctor'); 13 | return checks; 14 | } 15 | 16 | setup() { 17 | return [{ 18 | id: 'systemd', 19 | name: 'Systemd', 20 | enabled: ({instance, argv}) => !argv.local && 21 | (instance.config.get('process') === 'systemd' || (argv.stages && argv.stages.includes('systemd'))), 22 | task: (...args) => this._setup(...args), 23 | skip: ({instance}) => { 24 | if (fs.existsSync(`/lib/systemd/system/ghost_${instance.name}.service`)) { 25 | return 'Systemd service has already been set up. Skipping Systemd setup'; 26 | } 27 | 28 | return false; 29 | }, 30 | onUserSkip: ({instance, ui}) => { 31 | ui.log('Systemd setup skipped, reverting to local process manager', 'yellow'); 32 | instance.config.set('process', 'local').save(); 33 | } 34 | }]; 35 | } 36 | 37 | _setup({instance, ui}, task) { 38 | const uid = getUid(instance.dir); 39 | 40 | // getUid returns either the uid or null 41 | if (!uid) { 42 | return task.skip('The "ghost" user has not been created, try running `ghost setup linux-user` first'); 43 | } 44 | 45 | if (instance.config.get('process') !== 'systemd') { 46 | const currentProcessManager = instance.config.get('process'); 47 | ui.log(`Changing process manager from ${currentProcessManager} to systemd`, 'yellow'); 48 | instance.config.set('process', 'systemd').save(); 49 | } 50 | 51 | const serviceFilename = `ghost_${instance.name}.service`; 52 | const service = template(fs.readFileSync(path.join(__dirname, 'ghost.service.template'), 'utf8')); 53 | const contents = service({ 54 | name: instance.name, 55 | dir: process.cwd(), 56 | user: uid, 57 | environment: this.system.environment, 58 | ghost_exec_path: process.argv.slice(0,2).join(' ') 59 | }); 60 | 61 | return this.template(instance, contents, 'systemd service', serviceFilename, '/lib/systemd/system').then( 62 | () => this.ui.sudo('systemctl daemon-reload') 63 | ).catch((error) => { 64 | throw new ProcessError(error); 65 | }); 66 | } 67 | 68 | uninstall(instance) { 69 | const serviceFilename = `/lib/systemd/system/ghost_${instance.name}.service`; 70 | 71 | if (fs.existsSync(serviceFilename)) { 72 | return this.ui.sudo(`rm ${serviceFilename}`).catch(() => { 73 | throw new SystemError('Systemd service file link could not be removed, you will need to do this manually.'); 74 | }); 75 | } 76 | 77 | return Promise.resolve(); 78 | } 79 | } 80 | 81 | module.exports = SystemdExtension; 82 | -------------------------------------------------------------------------------- /extensions/systemd/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghost-cli-systemd", 3 | "version": "0.0.0", 4 | "keywords": [ 5 | "ghost-cli-extension" 6 | ], 7 | "main": "index.js", 8 | 9 | "ghost-cli": { 10 | "after": "ghost-cli-user", 11 | "process-managers": { 12 | "systemd": "systemd.js" 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /extensions/systemd/test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "prefer-arrow-callback": "off", 7 | "func-names": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /extensions/systemd/test/get-uid-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const proxyquire = require('proxyquire').noCallThru(); 5 | 6 | const modulePath = '../get-uid'; 7 | 8 | describe('Unit: Systemd > get-uid util', function () { 9 | it('catches error but returns null', function () { 10 | const shellStub = sinon.stub().throws(new Error('some error')); 11 | const getUid = proxyquire(modulePath, { 12 | execa: {shellSync: shellStub} 13 | }); 14 | 15 | const result = getUid('/some/dir'); 16 | expect(result).to.be.null; 17 | expect(shellStub.calledOnce).to.be.true; 18 | }); 19 | 20 | it('returns null if ghost user doesn\'t exist', function () { 21 | const shellStub = sinon.stub().throws(new Error('No such user')); 22 | const getUid = proxyquire(modulePath, { 23 | execa: {shellSync: shellStub} 24 | }); 25 | 26 | const result = getUid('/some/dir'); 27 | expect(result).to.be.null; 28 | expect(shellStub.calledOnce).to.be.true; 29 | }); 30 | 31 | it('returns null if owner of folder is not the ghost user', function () { 32 | const shellStub = sinon.stub().returns({stdout: '42'}); 33 | const lstatStub = sinon.stub().returns({uid: 1}); 34 | const getUid = proxyquire(modulePath, { 35 | fs: {lstatSync: lstatStub}, 36 | execa: {shellSync: shellStub} 37 | }); 38 | 39 | const result = getUid('/some/dir'); 40 | expect(result).to.be.null; 41 | expect(shellStub.calledOnce).to.be.true; 42 | expect(lstatStub.calledOnce).to.be.true; 43 | expect(lstatStub.calledWithExactly('/some/dir/content')).to.be.true; 44 | }); 45 | 46 | it('returns uid if owner of content folder is the ghost user', function () { 47 | const shellStub = sinon.stub().returns({stdout: '42'}); 48 | const lstatStub = sinon.stub().returns({uid: 42}); 49 | const getUid = proxyquire(modulePath, { 50 | fs: {lstatSync: lstatStub}, 51 | execa: {shellSync: shellStub} 52 | }); 53 | 54 | const result = getUid('/some/dir'); 55 | expect(result).to.equal('42'); 56 | expect(shellStub.calledOnce).to.be.true; 57 | expect(lstatStub.calledOnce).to.be.true; 58 | expect(lstatStub.calledWithExactly('/some/dir/content')).to.be.true; 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /lib/commands/backup.js: -------------------------------------------------------------------------------- 1 | const Command = require('../command'); 2 | 3 | class BackupCommand extends Command { 4 | async run() { 5 | const semver = require('semver'); 6 | 7 | const backupTask = require('../tasks/backup'); 8 | const {loadVersions} = require('../utils/version'); 9 | const {SystemError} = require('../errors'); 10 | 11 | const instance = this.system.getInstance(); 12 | const isRunning = await instance.isRunning(); 13 | 14 | if (!isRunning) { 15 | const shouldStart = await this.ui.confirm('Ghost instance is not currently running. Would you like to start it?', true); 16 | 17 | if (!shouldStart) { 18 | throw new SystemError('Ghost instance is not currently running'); 19 | } 20 | 21 | instance.checkEnvironment(); 22 | await this.ui.run(() => instance.start(), 'Starting Ghost'); 23 | } 24 | 25 | // Get the latest version in our current major 26 | const {latestMajor, latest} = await loadVersions(); 27 | const activeMajor = semver.major(instance.version); 28 | const latestMinor = latestMajor[`v${activeMajor}`]; 29 | 30 | const isBehindByMajor = ['major', 'premajor'].includes(semver.diff(instance.version, latest)); 31 | 32 | let backupFile; 33 | 34 | await this.ui.run(async () => { 35 | backupFile = await backupTask(this.ui, instance); 36 | }, 'Backing up site'); 37 | 38 | if (latestMinor && instance.version !== latestMinor && isBehindByMajor) { 39 | this.ui.log(`We strongly recommend running \`ghost backup\` again after upgrading to v${latestMinor} & before upgrading to v${semver.parse(latest).major}.`, 'yellow'); 40 | } 41 | 42 | this.ui.log(`Backup saved to ${backupFile}`, 'green'); 43 | } 44 | } 45 | 46 | BackupCommand.description = 'Backup content & files'; 47 | 48 | module.exports = BackupCommand; 49 | -------------------------------------------------------------------------------- /lib/commands/buster.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../command'); 3 | 4 | class BusterCommand extends Command { 5 | run() { 6 | const yarn = require('../utils/yarn'); 7 | 8 | return this.ui.run(yarn(['cache', 'clean']), 'Clearing yarn cache'); 9 | } 10 | } 11 | 12 | BusterCommand.description = 'Who ya gonna call? (Runs `yarn cache clean`)'; 13 | BusterCommand.longDescription = 'When there\'s something strange in your neighborhood....'; 14 | BusterCommand.global = true; 15 | 16 | module.exports = BusterCommand; 17 | -------------------------------------------------------------------------------- /lib/commands/check-update.js: -------------------------------------------------------------------------------- 1 | const Command = require('../command'); 2 | 3 | class CheckUpdateCommand extends Command { 4 | async run() { 5 | const chalk = require('chalk'); 6 | const startCase = require('lodash/startCase'); 7 | const semver = require('semver'); 8 | const {loadVersions} = require('../utils/version'); 9 | const instance = this.system.getInstance(); 10 | 11 | if (instance.version) { 12 | const {latest, latestMajor} = await loadVersions(); 13 | const currentMajor = semver.parse(instance.version).major; 14 | const latestMinor = latestMajor[`v${currentMajor}`]; 15 | 16 | this.ui.log(`Current version: ${chalk.cyan(instance.version)}`); 17 | 18 | if (latestMinor && semver.neq(latestMinor, instance.version) && semver.neq(latestMinor, latest)) { 19 | this.ui.log(`Latest ${currentMajor}.x version: ${chalk.cyan(latestMinor)}`); 20 | } 21 | 22 | this.ui.log(`Latest version: ${chalk.cyan(latest)}`); 23 | 24 | if (semver.gt(latest, instance.version)) { 25 | const diff = semver.diff(instance.version, latest); 26 | 27 | this.ui.log(`${startCase(diff)} update available!`, 'green'); 28 | } else { 29 | this.ui.log(`You're up to date!`, 'green'); 30 | } 31 | } 32 | } 33 | } 34 | 35 | CheckUpdateCommand.description = 'Check if an update is available for a Ghost installation'; 36 | CheckUpdateCommand.allowRoot = true; 37 | 38 | module.exports = CheckUpdateCommand; 39 | -------------------------------------------------------------------------------- /lib/commands/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../command'); 3 | const options = require('../tasks/configure/options'); 4 | 5 | class ConfigCommand extends Command { 6 | static configureSubcommands(commandName, commandArgs, extensions) { 7 | return commandArgs.command({ 8 | command: 'get ', 9 | describe: 'Get a specific value from the configuration file', 10 | handler: argv => this._run(`${commandName} get`, argv, extensions) 11 | }).command({ 12 | command: 'set ', 13 | describe: 'Set a specific value in the configuration file', 14 | handler: argv => this._run(`${commandName} set`, argv, extensions) 15 | }); 16 | } 17 | 18 | constructor(ui, system) { 19 | super(ui, system); 20 | 21 | this.instance = this.system.getInstance(); 22 | } 23 | 24 | async run(argv) { 25 | const {key, value} = argv; 26 | 27 | this.instance.checkEnvironment(); 28 | 29 | if (key && !value) { 30 | // getter 31 | if (this.instance.config.has(key)) { 32 | this.ui.log(this.instance.config.get(key)); 33 | } 34 | 35 | return; 36 | } else if (key) { 37 | // setter 38 | this.instance.config.set(key, value).save(); 39 | this.ui.log(`Successfully set '${key}' to '${value}'`, 'green'); 40 | 41 | // If the instance is running, we want to remind the user to restart 42 | // it so the new config can take effect. The isRunning check is only 43 | // a nicety, so if it fails, swallow the error for better UX. 44 | try { 45 | if (await this.instance.isRunning()) { 46 | const chalk = require('chalk'); 47 | this.ui.log( 48 | `Ghost is running. Don't forget to run ${chalk.cyan('ghost restart')} to reload the config!` 49 | ); 50 | } 51 | } catch (_) {} // eslint-disable-line no-empty 52 | 53 | return; 54 | } 55 | 56 | const configure = require('../tasks/configure'); 57 | await configure(this.ui, this.instance.config, argv, this.system.environment, false); 58 | } 59 | } 60 | 61 | ConfigCommand.description = 'View or edit Ghost configuration'; 62 | ConfigCommand.longDescription = '$0 config [key] [value]\n View or modify the configuration for a Ghost instance.'; 63 | ConfigCommand.params = '[key] [value]'; 64 | ConfigCommand.options = options; 65 | ConfigCommand.allowRoot = true; 66 | 67 | module.exports = ConfigCommand; 68 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/binary-deps.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const {SystemError} = require('../../../errors'); 3 | 4 | const taskTitle = 'Checking binary dependencies'; 5 | 6 | function binaryDeps(ctx, task) { 7 | if (!ctx.instance) { 8 | return task.skip('Instance not set'); 9 | } 10 | 11 | if (process.versions.node !== ctx.instance.nodeVersion) { 12 | const currentVersion = ctx.instance.version; 13 | 14 | throw new SystemError({ 15 | message: 'The installed node version has changed since Ghost was installed.', 16 | help: `Run ${chalk.green(`ghost update ${currentVersion} --force`)} to re-install binary dependencies.`, 17 | task: taskTitle 18 | }); 19 | } 20 | } 21 | 22 | module.exports = { 23 | title: taskTitle, 24 | task: binaryDeps, 25 | category: ['start'] 26 | }; 27 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/check-directory.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs-extra'); 3 | const Mode = require('stat-mode'); 4 | const path = require('path'); 5 | const isRoot = require('path-is-root'); 6 | 7 | const errors = require('../../../errors'); 8 | 9 | module.exports = function checkDirectoryAndAbove(dir, extra, task) { 10 | if (isRoot(dir)) { 11 | return Promise.resolve(); 12 | } 13 | 14 | return fs.lstat(dir).then((stats) => { 15 | const mode = new Mode(stats); 16 | 17 | if (!mode.others.read) { 18 | return Promise.reject(new errors.SystemError({ 19 | message: `The directory ${dir} is not readable by other users on the system. 20 | This can cause issues with the CLI, you must either make this directory readable by others or ${extra} in another location.`, 21 | task: task 22 | })); 23 | } 24 | 25 | return checkDirectoryAndAbove(path.join(dir, '../'), extra); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/check-memory.js: -------------------------------------------------------------------------------- 1 | const sysinfo = require('systeminformation'); 2 | const {SystemError} = require('../../../errors'); 3 | 4 | const MB_IN_BYTES = 1048576; 5 | const MIN_MEMORY = 150; 6 | 7 | async function checkMemory() { 8 | const {available, swapfree} = await sysinfo.mem(); 9 | const availableMemory = (available + swapfree) / MB_IN_BYTES; 10 | 11 | if (availableMemory < MIN_MEMORY) { 12 | throw new SystemError(`You are recommended to have at least ${MIN_MEMORY} MB of memory available for smooth operation. It looks like you have ~${availableMemory} MB available.`); 13 | } 14 | } 15 | 16 | module.exports = { 17 | title: 'Checking memory availability', 18 | task: checkMemory, 19 | enabled: ctx => ctx.argv['check-mem'] === true, 20 | category: ['install', 'start', 'update'] 21 | }; 22 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/check-permissions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const execa = require('execa'); 4 | const chalk = require('chalk'); 5 | 6 | const errors = require('../../../errors'); 7 | 8 | module.exports = function checkPermissions(type, task) { 9 | let errMsg; 10 | 11 | // fall back to check the owner permission of the content folder if nothing specified 12 | type = type || 'owner'; 13 | 14 | const checkTypes = { 15 | owner: { 16 | command: 'find ./content ! -group ghost ! -user ghost', 17 | help: `Run ${chalk.green('sudo chown -R ghost:ghost ./content')} and try again.` 18 | }, 19 | folder: { 20 | command: 'find ./ -type d ! -perm 775 ! -perm 755', 21 | // chmod mode from http://man7.org/linux/man-pages/man1/chmod.1.html#SETUID_AND_SETGID_BITS 22 | help: `Run ${chalk.green('sudo find ./ -type d -exec chmod 00775 {} \\;')} and try again.` 23 | }, 24 | files: { 25 | command: 'find ./ -type f ! -path "./versions/*" ! -perm 664 ! -perm 644', 26 | help: `Run ${chalk.green('sudo find ./ ! -path "./versions/*" -type f -exec chmod 664 {} \\;')} and try again.` 27 | } 28 | }; 29 | 30 | return execa.shell(checkTypes[type].command, {maxBuffer: Infinity}).then((result) => { 31 | if (!result.stdout) { 32 | return Promise.resolve(); 33 | } 34 | const resultDirs = result.stdout.split('\n'); 35 | const dirWording = resultDirs.length > 1 ? 'some directories or files' : 'a directory or file'; 36 | 37 | errMsg = `Your installation folder contains ${dirWording} with incorrect permissions:\n`; 38 | 39 | resultDirs.forEach((folder) => { 40 | errMsg += `- ${folder}\n`; 41 | }); 42 | 43 | errMsg += checkTypes[type].help; 44 | 45 | return Promise.reject(new errors.SystemError({ 46 | message: errMsg, 47 | task: task 48 | })); 49 | }).catch((error) => { 50 | if (error instanceof errors.SystemError) { 51 | return Promise.reject(error); 52 | } 53 | 54 | if (error.stderr && error.stderr.match(/Permission denied/i)) { 55 | // CASE: We can't access the files or directories. 56 | // Print the help command for folder permissions to fix that 57 | errMsg = 'Ghost can\'t access some files or directories to check for correct permissions.'; 58 | 59 | return Promise.reject(new errors.SystemError({ 60 | message: errMsg, 61 | help: checkTypes.folder.help, 62 | err: error, 63 | task: task 64 | })); 65 | } 66 | 67 | error.task = task; 68 | return Promise.reject(new errors.ProcessError(error)); 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/content-folder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | const checkPermissions = require('./check-permissions'); 6 | const ghostUser = require('../../../utils/use-ghost-user'); 7 | 8 | const taskTitle = 'Checking content folder ownership'; 9 | 10 | module.exports = { 11 | title: taskTitle, 12 | enabled: () => ghostUser.shouldUseGhostUser(path.join(process.cwd(), 'content')), 13 | task: () => checkPermissions('owner', taskTitle), 14 | category: ['start', 'update'] 15 | }; 16 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/file-permissions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const checkPermissions = require('./check-permissions'); 4 | 5 | const taskTitle = 'Checking file permissions'; 6 | 7 | module.exports = { 8 | title: taskTitle, 9 | enabled: ({instance}) => instance && instance.process.name !== 'local', 10 | task: () => checkPermissions('files', taskTitle), 11 | category: ['start', 'update'] 12 | }; 13 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/folder-permissions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const checkPermissions = require('./check-permissions'); 4 | 5 | const taskTitle = 'Checking folder permissions'; 6 | 7 | module.exports = { 8 | title: taskTitle, 9 | enabled: ({instance}) => instance && instance.process.name !== 'local', 10 | task: () => checkPermissions('folder', taskTitle), 11 | category: ['start', 'update'] 12 | }; 13 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/free-space.js: -------------------------------------------------------------------------------- 1 | const sysinfo = require('systeminformation'); 2 | const {SystemError} = require('../../../errors'); 3 | 4 | const MB_IN_BYTES = 1048576; 5 | 6 | // one version of Ghost takes ~400mb of space currently, to be safe we're going to say 1gb 7 | const MIN_FREE_SPACE = 1024; 8 | 9 | async function checkFreeSpace(ctx) { 10 | const dir = ctx.instance ? ctx.instance.dir : process.cwd(); 11 | 12 | const disks = await sysinfo.fsSize(); 13 | 14 | // filter out disks with matching mount points, then sort in descending order by mount point length 15 | // to get the mount point with greatest specificity 16 | const [disk] = disks.filter(d => dir.startsWith(d.mount)).sort((a, b) => b.mount.length - a.mount.length); 17 | 18 | if (!disk) { 19 | // couldn't find a matching disk, early return 20 | // TODO: maybe throw a warning of some sort here? 21 | return; 22 | } 23 | 24 | const available = (disk.size - disk.used) / MB_IN_BYTES; 25 | if (available < MIN_FREE_SPACE) { 26 | throw new SystemError(`You are recommended to have at least ${MIN_FREE_SPACE} MB of free storage space available for smooth operation. It looks like you have ~${available} MB available`); 27 | } 28 | } 29 | 30 | module.exports = { 31 | title: 'Checking free space', 32 | task: checkFreeSpace, 33 | category: ['install', 'update'] 34 | }; 35 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const nodeVersion = require('./node-version'); 3 | const loggedInUser = require('./logged-in-user'); 4 | const loggedInGhostUser = require('./logged-in-ghost-user'); 5 | const loggedInUserOwner = require('./logged-in-user-owner'); 6 | const installFolderPermissions = require('./install-folder-permissions'); 7 | const systemStack = require('./system-stack'); 8 | const mysqlCheck = require('./mysql'); 9 | const validateConfig = require('./validate-config'); 10 | const folderPermissions = require('./folder-permissions'); 11 | const filePermissions = require('./file-permissions'); 12 | const contentFolder = require('./content-folder'); 13 | const checkMemory = require('./check-memory'); 14 | const binaryDeps = require('./binary-deps'); 15 | const freeSpace = require('./free-space'); 16 | 17 | module.exports = [ 18 | nodeVersion, 19 | loggedInUser, 20 | loggedInGhostUser, 21 | loggedInUserOwner, 22 | installFolderPermissions, 23 | systemStack, 24 | mysqlCheck, 25 | validateConfig, 26 | folderPermissions, 27 | filePermissions, 28 | contentFolder, 29 | checkMemory, 30 | binaryDeps, 31 | freeSpace 32 | ]; 33 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/install-folder-permissions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('node:fs/promises'); 3 | const constants = require('constants'); 4 | const chalk = require('chalk'); 5 | 6 | const errors = require('../../../errors'); 7 | const checkDirectoryAndAbove = require('./check-directory'); 8 | 9 | const taskTitle = 'Checking current folder permissions'; 10 | 11 | async function installFolderPermissions(ctx) { 12 | try { 13 | await fs.access(process.cwd(), constants.R_OK | constants.W_OK); 14 | } catch (_) { 15 | throw new errors.SystemError({ 16 | message: `The directory ${process.cwd()} is not writable by your user. You must grant write access and try again.`, 17 | help: `${chalk.green('https://ghost.org/docs/install/ubuntu/#create-a-directory')}`, 18 | task: taskTitle 19 | }); 20 | } 21 | 22 | if (ctx.local || !ctx.system.platform.linux || (ctx.argv && ctx.argv['setup-linux-user'] === false)) { 23 | return; 24 | } 25 | 26 | return checkDirectoryAndAbove(process.cwd(), 'run `ghost install`', taskTitle); 27 | } 28 | 29 | module.exports = { 30 | title: taskTitle, 31 | task: installFolderPermissions, 32 | category: ['install', 'update', 'start'] 33 | }; 34 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/logged-in-ghost-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const errors = require('../../../errors'); 4 | const chalk = require('chalk'); 5 | const fs = require('fs'); 6 | const ghostUser = require('../../../utils/use-ghost-user'); 7 | 8 | const taskTitle = 'Ensuring user is not logged in as ghost user'; 9 | 10 | function loggedInGhostUser() { 11 | const uid = process.getuid(); 12 | const contentDirStats = fs.lstatSync(path.join(process.cwd(), 'content')); 13 | 14 | const ghostStats = ghostUser.getGhostUid(); 15 | 16 | // check if ghost user exists and if it's currently used 17 | if (ghostStats && ghostStats.uid && ghostStats.uid === uid) { 18 | // The ghost user might have been set up on the system and also used, 19 | // but only when it owns the content folder, it's an indication that it's also used 20 | // as the linux user and shall not be used as current user. 21 | if (contentDirStats.uid === ghostStats.uid) { 22 | throw new errors.SystemError({ 23 | message: 'You can\'t run commands with the "ghost" user. Switch to your own user and try again.', 24 | help: `${chalk.green('https://ghost.org/docs/install/ubuntu/#create-a-new-user')}`, 25 | task: taskTitle 26 | }); 27 | } 28 | } 29 | } 30 | 31 | module.exports = { 32 | title: taskTitle, 33 | task: loggedInGhostUser, 34 | enabled: ({system}) => system.platform.linux, 35 | skip: ({instance}) => instance && instance.process.name === 'local', 36 | category: ['start', 'update'] 37 | }; 38 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/logged-in-user-owner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const errors = require('../../../errors'); 4 | const chalk = require('chalk'); 5 | const fs = require('fs'); 6 | 7 | const taskTitle = 'Checking if logged in user is directory owner'; 8 | 9 | function loggedInUserOwner(ctx) { 10 | // TODO: switch to require('os').userInfo() and output username in errors 11 | const uid = process.getuid(); 12 | const gid = process.getgroups(); 13 | const dir = process.cwd(); 14 | const dirStats = fs.lstatSync(path.join(dir)); 15 | 16 | // check if the current user is the owner of the current dir 17 | if (dirStats.uid !== uid) { 18 | if (gid.indexOf(dirStats.gid) < 0) { 19 | throw new errors.SystemError({ 20 | message: `Your user does not own the directory ${dir} and is also not a member of the owning group. 21 | You must either log in with the user that owns the directory or add your user to the owning group.`, 22 | help: `${chalk.green('https://ghost.org/docs/install/ubuntu/#create-a-new-user')}`, 23 | task: taskTitle 24 | }); 25 | } 26 | // Yup current user is not the owner, but in the same group, so just show a warning 27 | ctx.ui.log(`Your user does not own the directory ${dir}. This might cause permission issues.`, 'yellow'); 28 | } 29 | } 30 | 31 | module.exports = { 32 | title: taskTitle, 33 | task: loggedInUserOwner, 34 | enabled: ({system}) => system.platform.linux, 35 | skip: ({instance}) => instance && instance.process.name === 'local', 36 | category: ['start', 'update'] 37 | }; 38 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/logged-in-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const errors = require('../../../errors'); 3 | const chalk = require('chalk'); 4 | const ghostUser = require('../../../utils/use-ghost-user'); 5 | 6 | const taskTitle = 'Checking logged in user'; 7 | 8 | function loggedInUser() { 9 | const uid = process.getuid(); 10 | const ghostStats = ghostUser.getGhostUid(); 11 | 12 | if (ghostStats && ghostStats.uid === uid) { 13 | throw new errors.SystemError({ 14 | message: 'You can\'t run install commands with a user called "ghost". Switch to a different user and try again.', 15 | help: `${chalk.green('https://ghost.org/docs/install/ubuntu/#create-a-new-user')}`, 16 | task: taskTitle 17 | }); 18 | } 19 | 20 | return; 21 | } 22 | 23 | module.exports = { 24 | title: taskTitle, 25 | task: loggedInUser, 26 | enabled: ctx => !ctx.local && !(ctx.instance && ctx.instance.process.name === 'local') && ctx.system.platform.linux && !(ctx.argv && ctx.argv.process === 'local'), 27 | category: ['install'] 28 | }; 29 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/mysql.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const sysinfo = require('systeminformation'); 3 | 4 | const {SystemError} = require('../../../errors'); 5 | 6 | const taskTitle = 'Checking for a MySQL installation'; 7 | 8 | async function mysqlIsRunning() { 9 | try { 10 | const services = await sysinfo.services('mysql'); 11 | return services.some(s => s.name === 'mysql' && s.running); 12 | } catch (error) { 13 | return false; 14 | } 15 | } 16 | 17 | async function mysqlCheck(ctx, task) { 18 | if (await mysqlIsRunning()) { 19 | // mysql service found that is also running, so return 20 | return; 21 | } 22 | 23 | ctx.ui.log(`${chalk.yellow(`Local MySQL install was not found or is stopped. You can ignore this if you are using a remote MySQL host. 24 | Alternatively you could:`)} 25 | ${chalk.blue('a)')} install/start MySQL locally 26 | ${chalk.blue('b)')} run ${chalk.cyan('`ghost install --db=sqlite3`')} to use sqlite 27 | ${chalk.blue('c)')} run ${chalk.cyan('`ghost install local`')} to get a development install using sqlite3.`); 28 | 29 | const confirm = await ctx.ui.confirm(chalk.blue('Continue anyway?'), false); 30 | if (confirm) { 31 | task.skip('MySQL check skipped'); 32 | return; 33 | } 34 | 35 | throw new SystemError({ 36 | message: 'MySQL check failed.', 37 | task: taskTitle 38 | }); 39 | } 40 | 41 | function mysqlIsEnabled(ctx) { 42 | // Case 1: instance is already set, which means this check 43 | // is being run post-install. In this case, check the config for sqlite3 44 | // and an external mysql db. If either are found, check is disabled 45 | if (ctx.instance) { 46 | // instance is set, this check is being run post-install 47 | return ctx.instance.config.get('database.client') !== 'sqlite3' && 48 | ['localhost', '127.0.0.1'].includes(ctx.instance.config.get('database.connection.host')); 49 | } 50 | 51 | // Case 2: Disable this check if 52 | // a) local install OR 53 | // b) --db sqlite3 is passed OR 54 | // c) --dbhost is passed and IS NOT 'localhost' or '127.0.0.1' 55 | return !ctx.local && ctx.argv.db !== 'sqlite3' && 56 | (!ctx.argv.dbhost || ['localhost', '127.0.0.1'].includes(ctx.argv.dbhost)); 57 | } 58 | 59 | module.exports = { 60 | title: taskTitle, 61 | task: mysqlCheck, 62 | // Disable this check if: 63 | // a) local install OR 64 | // b) --db sqlite3 is passed OR 65 | // c) --dbhost is passed and IS NOT 'localhost' or '127.0.0.1' 66 | enabled: mysqlIsEnabled, 67 | category: ['install'] 68 | }; 69 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/node-version.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const semver = require('semver'); 3 | 4 | const errors = require('../../../errors'); 5 | const cliPackage = require('../../../../package'); 6 | const checkDirectoryAndAbove = require('./check-directory'); 7 | 8 | const taskTitle = 'Checking system Node.js version'; 9 | 10 | async function nodeVersion(ctx, task) { 11 | const {node} = process.versions; 12 | task.title = `${taskTitle} - found v${node}`; 13 | 14 | if (process.env.GHOST_NODE_VERSION_CHECK !== 'false' && !semver.satisfies(node, cliPackage.engines.node)) { 15 | throw new errors.SystemError({ 16 | message: `${chalk.red('The version of Node.js you are using is not supported.')} 17 | ${chalk.gray('Supported: ')}${cliPackage.engines.node} 18 | ${chalk.gray('Installed: ')}${process.versions.node} 19 | See ${chalk.underline.blue('https://ghost.org/docs/faq/node-versions/')} for more information`, 20 | task: taskTitle 21 | }); 22 | } 23 | 24 | if (ctx.local || !ctx.system.platform.linux || (ctx.argv && ctx.argv['setup-linux-user'] === false)) { 25 | return; 26 | } 27 | 28 | return checkDirectoryAndAbove(process.argv[0], 'install node and Ghost-CLI', taskTitle); 29 | } 30 | 31 | module.exports = { 32 | title: taskTitle, 33 | task: nodeVersion, 34 | category: ['install', 'update', 'start'] 35 | }; 36 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/system-stack.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const sysinfo = require('systeminformation'); 3 | 4 | const {SystemError} = require('../../../errors'); 5 | 6 | const taskTitle = 'Checking system compatibility'; 7 | const nginxProgramName = process.env.NGINX_PROGRAM_NAME || 'nginx'; 8 | const versionRegex = /^(?:16|18|20|22)/; 9 | 10 | async function hasService(name) { 11 | try { 12 | const services = await sysinfo.services(name); 13 | return services.some(s => s.name === name && s.running); 14 | } catch (error) { 15 | return false; 16 | } 17 | } 18 | 19 | async function checkSystem(ctx) { 20 | if (!ctx.system.platform.linux) { 21 | throw new Error('Operating system is not Linux'); 22 | } 23 | 24 | const {distro, release} = await sysinfo.osInfo(); 25 | if (distro !== 'Ubuntu' || !versionRegex.test(release)) { 26 | throw new Error('Linux version is not Ubuntu 16, 18, 20, or 22'); 27 | } 28 | 29 | const missing = []; 30 | 31 | if (!(await hasService('systemd'))) { 32 | missing.push('systemd'); 33 | } 34 | 35 | if (!(await hasService(nginxProgramName))) { 36 | missing.push('nginx'); 37 | } 38 | 39 | if (missing.length) { 40 | throw new Error(`Missing package(s): ${missing.join(', ')}`); 41 | } 42 | } 43 | 44 | async function systemStack(ctx, task) { 45 | try { 46 | await checkSystem(ctx); 47 | } catch (error) { 48 | ctx.ui.log( 49 | `System checks failed with message: '${error.message}' 50 | Some features of Ghost-CLI may not work without additional configuration. 51 | For local installs we recommend using \`ghost install local\` instead.`, 52 | 'yellow' 53 | ); 54 | 55 | const skip = await ctx.ui.confirm(chalk.blue('Continue anyway?'), false); 56 | if (skip) { 57 | task.skip('System stack check skipped'); 58 | return; 59 | } 60 | 61 | throw new SystemError({ 62 | message: `System stack checks failed with message: '${error.message}'`, 63 | task: taskTitle 64 | }); 65 | } 66 | } 67 | 68 | module.exports = { 69 | title: taskTitle, 70 | task: systemStack, 71 | enabled: ctx => !ctx.local && !(ctx.instance && ctx.instance.process.name === 'local'), 72 | skip: ctx => !ctx.isDoctorCommand && ctx.argv && !ctx.argv.stack, 73 | category: ['install'] 74 | }; 75 | -------------------------------------------------------------------------------- /lib/commands/doctor/checks/validate-config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const get = require('lodash/get'); 3 | const path = require('path'); 4 | const filter = require('lodash/filter'); 5 | const Promise = require('bluebird'); 6 | 7 | const errors = require('../../../errors'); 8 | const Config = require('../../../utils/config'); 9 | const options = require('../../../tasks/configure/options'); 10 | 11 | const taskTitle = 'Validating config'; 12 | 13 | function validateConfig(ctx, task) { 14 | if (!ctx.instance) { 15 | return task.skip('Instance not set'); 16 | } 17 | 18 | return ctx.instance.isRunning().then((isRunning) => { 19 | if (isRunning) { 20 | return task.skip('Instance is currently running'); 21 | } 22 | 23 | const config = Config.exists(path.join(process.cwd(), `config.${ctx.system.environment}.json`)); 24 | 25 | if (config === false) { 26 | return Promise.reject(new errors.ConfigError({ 27 | environment: ctx.system.environment, 28 | message: 'Config file is not valid JSON', 29 | task: taskTitle 30 | })); 31 | } 32 | 33 | const configValidations = filter(options, cfg => cfg.validate); 34 | 35 | return Promise.each(configValidations, (configItem) => { 36 | const key = configItem.configPath || configItem.name; 37 | const value = get(config, key); 38 | 39 | if (!value) { 40 | return; 41 | } 42 | 43 | return Promise.resolve(configItem.validate(value)).then((validated) => { 44 | if (validated !== true) { 45 | return Promise.reject(new errors.ConfigError({ 46 | config: { 47 | [key]: value 48 | }, 49 | message: validated, 50 | environment: ctx.system.environment, 51 | task: taskTitle 52 | })); 53 | } 54 | }); 55 | }); 56 | }); 57 | } 58 | 59 | module.exports = { 60 | title: taskTitle, 61 | task: validateConfig, 62 | category: ['start'] 63 | }; 64 | -------------------------------------------------------------------------------- /lib/commands/doctor/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../../command'); 3 | 4 | class DoctorCommand extends Command { 5 | run(argv) { 6 | const intersection = require('lodash/intersection'); 7 | const flatten = require('lodash/flatten'); 8 | const checks = require('./checks'); 9 | 10 | return this.system.hook('doctor').then((extensionChecks) => { 11 | const combinedChecks = checks.concat(flatten(extensionChecks).filter(Boolean)); 12 | 13 | const checksToRun = argv.categories && argv.categories.length ? 14 | combinedChecks.filter(check => intersection(argv.categories, check.category || []).length) : 15 | combinedChecks; 16 | 17 | if (!checksToRun.length) { 18 | if (!argv.quiet) { 19 | const additional = argv.categories && argv.categories.length ? ` for categories "${argv.categories.join(', ')}"` : ''; 20 | this.ui.log(`No checks found to run${additional}.`); 21 | } 22 | 23 | return Promise.resolve(); 24 | } 25 | 26 | let instance; 27 | 28 | if ( 29 | !argv.skipInstanceCheck && 30 | !(argv.categories && argv.categories.length === 1 && argv.categories[0] === 'install') 31 | ) { 32 | const findValidInstall = require('../../utils/find-valid-install'); 33 | 34 | findValidInstall('doctor'); 35 | instance = this.system.getInstance(); 36 | instance.checkEnvironment(); 37 | } else { 38 | instance = this.system.getInstance(); 39 | } 40 | 41 | const context = { 42 | argv: argv, 43 | system: this.system, 44 | instance: instance, 45 | ui: this.ui, 46 | local: argv.local || false, 47 | // This is set to true whenever the command is `ghost doctor` itself, 48 | // rather than something like `ghost start` or `ghost update` 49 | isDoctorCommand: Boolean(argv._ && argv._.length && argv._[0] === 'doctor') 50 | }; 51 | 52 | return this.ui.listr(checksToRun, context, {exitOnError: false}); 53 | }); 54 | } 55 | } 56 | 57 | DoctorCommand.description = 'Check the system for any potential hiccups when installing/updating Ghost'; 58 | DoctorCommand.longDescription = '$0 doctor [categories..]\n Run various checks to determine potential problems with your environment.'; 59 | DoctorCommand.params = '[categories..]'; 60 | DoctorCommand.global = true; 61 | DoctorCommand.options = { 62 | 'check-mem': { 63 | alias: 'mem-check', 64 | description: '[--no-check-mem] Enable/Disable memory availability checks', 65 | type: 'boolean', 66 | default: true 67 | } 68 | }; 69 | 70 | module.exports = DoctorCommand; 71 | -------------------------------------------------------------------------------- /lib/commands/export.js: -------------------------------------------------------------------------------- 1 | const Command = require('../command'); 2 | 3 | class ExportCommand extends Command { 4 | async run(argv) { 5 | const {exportTask} = require('../tasks/import'); 6 | const {SystemError} = require('../errors'); 7 | 8 | const instance = this.system.getInstance(); 9 | const isRunning = await instance.isRunning(); 10 | 11 | if (!isRunning) { 12 | const shouldStart = await this.ui.confirm('Ghost instance is not currently running. Would you like to start it?', true); 13 | 14 | if (!shouldStart) { 15 | throw new SystemError('Ghost instance is not currently running'); 16 | } 17 | 18 | instance.checkEnvironment(); 19 | await this.ui.run(() => instance.start(), 'Starting Ghost'); 20 | } 21 | 22 | await this.ui.run(() => exportTask(this.ui, instance, argv.file), 'Exporting content'); 23 | this.ui.log(`Content exported to ${argv.file}`, 'green'); 24 | } 25 | } 26 | 27 | ExportCommand.description = 'Export content from a blog'; 28 | ExportCommand.params = 'file'; 29 | 30 | module.exports = ExportCommand; 31 | -------------------------------------------------------------------------------- /lib/commands/import.js: -------------------------------------------------------------------------------- 1 | const Command = require('../command'); 2 | 3 | class ImportCommand extends Command { 4 | async run(argv) { 5 | const semver = require('semver'); 6 | const {importTask, parseExport} = require('../tasks/import'); 7 | const {SystemError} = require('../errors'); 8 | 9 | const instance = this.system.getInstance(); 10 | const {version} = parseExport(argv.file); 11 | 12 | if (semver.major(version) === 0 && semver.major(instance.version) > 1) { 13 | throw new SystemError(`v0.x export files can only be imported by Ghost v1.x versions. You are running Ghost v${instance.version}.`); 14 | } 15 | 16 | const isRunning = await instance.isRunning(); 17 | 18 | if (!isRunning) { 19 | const shouldStart = await this.ui.confirm('Ghost instance is not currently running. Would you like to start it?', true); 20 | 21 | if (!shouldStart) { 22 | throw new SystemError('Ghost instance is not currently running'); 23 | } 24 | 25 | instance.checkEnvironment(); 26 | await this.ui.run(() => instance.start(), 'Starting Ghost'); 27 | } 28 | 29 | const importTasks = await importTask(this.ui, instance, argv.file); 30 | await importTasks.run(); 31 | } 32 | } 33 | 34 | ImportCommand.description = 'Import a Ghost export'; 35 | ImportCommand.params = '[file]'; 36 | 37 | module.exports = ImportCommand; 38 | -------------------------------------------------------------------------------- /lib/commands/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../command'); 3 | 4 | class LogCommand extends Command { 5 | run(argv) { 6 | const fs = require('fs'); 7 | const path = require('path'); 8 | const lastLines = require('read-last-lines'); 9 | const findValidInstall = require('../utils/find-valid-install'); 10 | 11 | const errors = require('../errors'); 12 | const PrettyStream = require('../ui/pretty-stream'); 13 | 14 | if (!argv.name) { 15 | findValidInstall('log', true); 16 | } 17 | 18 | const instance = this.system.getInstance(argv.name); 19 | 20 | if (!instance) { 21 | return Promise.reject(new errors.SystemError(`Ghost instance '${argv.name}' does not exist`)); 22 | } 23 | 24 | return instance.isRunning().then((running) => { 25 | if (!running) { 26 | instance.checkEnvironment(); 27 | } 28 | 29 | // Check if logging file transport is set in config 30 | if (!instance.config.get('logging.transports', []).includes('file')) { 31 | // TODO: fallback to process manager log retrieval? 32 | return Promise.reject(new errors.ConfigError({ 33 | config: { 34 | 'logging.transports': instance.config.get('logging.transports', []).join(', ') 35 | }, 36 | message: 'You have excluded file logging in your ghost config. ' + 37 | 'You need to add it to your transport config to use this command.', 38 | environment: this.system.environment 39 | })); 40 | } 41 | 42 | const logFileName = path.join(instance.dir, 'content/logs', `${instance.config.get('url').replace(/[^\w]/gi, '_')}_${this.system.environment}${argv.error ? '.error' : ''}.log`); 43 | const prettyStream = new PrettyStream(); 44 | 45 | if (!fs.existsSync(logFileName)) { 46 | if (argv.follow) { 47 | this.ui.log('Log file has not been created yet, `--follow` only works on existing files', 'yellow'); 48 | } 49 | 50 | return Promise.resolve(); 51 | } 52 | 53 | prettyStream.on('error', (error) => { 54 | if (!(error instanceof SyntaxError)) { 55 | throw error; 56 | } 57 | }); 58 | 59 | prettyStream.pipe(this.ui.stdout); 60 | 61 | return lastLines.read(logFileName, argv.number).then((lines) => { 62 | lines.trim().split('\n').forEach(line => prettyStream.write(line)); 63 | 64 | if (argv.follow) { 65 | const Tail = require('tail').Tail; 66 | 67 | const tail = new Tail(logFileName); 68 | tail.on('line', line => prettyStream.write(line, 'utf8')); 69 | } 70 | }); 71 | }); 72 | } 73 | } 74 | 75 | LogCommand.description = 'View the logs of a Ghost instance'; 76 | LogCommand.params = '[name]'; 77 | LogCommand.options = { 78 | number: { 79 | alias: 'n', 80 | description: 'Number of lines to view', 81 | default: 20, 82 | type: 'number' 83 | }, 84 | follow: { 85 | alias: 'f', 86 | description: 'Follow the log file (similar to `tail -f`)', 87 | type: 'boolean' 88 | }, 89 | error: { 90 | alias: 'e', 91 | description: 'If provided, only show the error log', 92 | type: 'boolean' 93 | } 94 | }; 95 | LogCommand.global = true; 96 | 97 | module.exports = LogCommand; 98 | -------------------------------------------------------------------------------- /lib/commands/ls.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../command'); 3 | 4 | class LsCommand extends Command { 5 | async run() { 6 | const chalk = require('chalk'); 7 | const Promise = require('bluebird'); 8 | 9 | function makeRow(summary) { 10 | const {running, name, dir, version, mode, url, port, process} = summary; 11 | 12 | if (!running) { 13 | return [name, dir, version, chalk.red('stopped'), chalk.red('n/a'), chalk.red('n/a'), chalk.red('n/a')]; 14 | } 15 | 16 | return [name, dir, version, `${chalk.green('running')} (${mode})`, url, port, process]; 17 | } 18 | 19 | const instances = await this.system.getAllInstances(); 20 | const rows = await Promise.map(instances, async (instance) => { 21 | const summary = await instance.summary(); 22 | return makeRow(summary); 23 | }); 24 | 25 | if (rows.length) { 26 | this.ui.table(['Name', 'Location', 'Version', 'Status', 'URL', 'Port', 'Process Manager'], rows, { 27 | style: {head: ['cyan']} 28 | }); 29 | } else { 30 | this.ui.log('No installed ghost instances found', 'cyan'); 31 | } 32 | } 33 | } 34 | 35 | LsCommand.description = 'View running ghost processes'; 36 | LsCommand.global = true; 37 | 38 | module.exports = LsCommand; 39 | -------------------------------------------------------------------------------- /lib/commands/migrate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../command'); 3 | 4 | class MigrateCommand extends Command { 5 | run(argv) { 6 | const parseNeededMigrations = require('../utils/needed-migrations'); 7 | 8 | const instance = this.system.getInstance(); 9 | 10 | return this.ui.run( 11 | () => this.system.hook('migrations'), 12 | 'Checking for available migrations' 13 | ).then((extensionMigrations) => { 14 | const neededMigrations = parseNeededMigrations( 15 | instance.cliVersion, 16 | this.system.cliVersion, 17 | extensionMigrations 18 | ); 19 | 20 | if (!neededMigrations.length) { 21 | if (!argv.quiet) { 22 | this.ui.log('No migrations needed :)', 'green'); 23 | } 24 | 25 | return Promise.resolve(); 26 | } 27 | 28 | return this.ui.listr(neededMigrations, {instance: instance}); 29 | }).then(() => { 30 | // Update the cli version in the cli config file 31 | instance.cliVersion = this.system.cliVersion; 32 | }); 33 | } 34 | } 35 | 36 | MigrateCommand.description = 'Run system migrations on a Ghost instance'; 37 | MigrateCommand.runPreChecks = true; 38 | 39 | module.exports = MigrateCommand; 40 | -------------------------------------------------------------------------------- /lib/commands/restart.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../command'); 3 | 4 | class RestartCommand extends Command { 5 | async run() { 6 | const instance = this.system.getInstance(); 7 | 8 | const isRunning = await instance.isRunning(); 9 | if (!isRunning) { 10 | this.ui.log('Ghost instance is not running! Starting...', 'yellow'); 11 | return this.ui.run(() => instance.start(), 'Starting Ghost'); 12 | } 13 | 14 | instance.loadRunningEnvironment(true); 15 | await this.ui.run(() => instance.restart(), 'Restarting Ghost'); 16 | } 17 | } 18 | 19 | RestartCommand.description = 'Restart the Ghost instance'; 20 | module.exports = RestartCommand; 21 | -------------------------------------------------------------------------------- /lib/commands/start.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../command'); 3 | const DoctorCommand = require('./doctor'); 4 | 5 | class StartCommand extends Command { 6 | static configureOptions(commandName, yargs, extensions) { 7 | const get = require('lodash/get'); 8 | const omit = require('lodash/omit'); 9 | 10 | extensions.forEach((extension) => { 11 | const options = get(extension, 'config.options.start', false); 12 | if (!options) { 13 | return; 14 | } 15 | 16 | Object.assign(this.options, omit(options, Object.keys(this.options))); 17 | }); 18 | 19 | yargs = super.configureOptions(commandName, yargs, extensions); 20 | yargs = DoctorCommand.configureOptions('doctor', yargs, extensions, true); 21 | 22 | return yargs; 23 | } 24 | 25 | async run(argv) { 26 | const getInstance = require('../utils/get-instance'); 27 | 28 | const runOptions = {quiet: argv.quiet}; 29 | const instance = getInstance({ 30 | name: argv.name, 31 | system: this.system, 32 | command: 'start', 33 | recurse: !argv.dir 34 | }); 35 | 36 | argv.local = instance.isLocal; 37 | const isRunning = await instance.isRunning(); 38 | if (isRunning) { 39 | this.ui.log('Ghost is already running! For more information, run', 'ghost ls', 'green', 'cmd', true); 40 | return; 41 | } 42 | 43 | instance.checkEnvironment(); 44 | 45 | if (this.system.environment === 'production' && instance.config.get('url', '').startsWith('http://')) { 46 | this.ui.log([ 47 | 'Using https on all URLs is highly recommended. In production, SSL is required when using Stripe.', 48 | 'Support for non-https admin URLs in production mode is deprecated and will be removed in a future version.' 49 | ].join('\n'), 'yellow'); 50 | } 51 | 52 | await this.runCommand(DoctorCommand, {categories: ['start'], ...argv, quiet: true, skipInstanceCheck: true}); 53 | await this.ui.run(() => instance.start(argv.enable), `Starting Ghost: ${instance.name}`, runOptions); 54 | 55 | if (!argv.quiet) { 56 | let adminUrl = instance.config.get('admin.url', instance.config.get('url', '')); 57 | // Strip the trailing slash and add the admin path 58 | adminUrl = `${adminUrl.replace(/\/$/,'')}/ghost/`; 59 | 60 | this.ui.log('\n------------------------------------------------------------------------------', 'white'); 61 | this.ui.log('Your admin interface is located at', adminUrl, 'green', 'link', true); 62 | } 63 | } 64 | } 65 | 66 | StartCommand.description = 'Start an instance of Ghost'; 67 | StartCommand.params = '[name]'; 68 | StartCommand.options = { 69 | enable: { 70 | description: '[--no-enable] Enable/don\'t enable instance restart on server reboot (if the process manager supports it)', 71 | type: 'boolean', 72 | default: true 73 | } 74 | }; 75 | StartCommand.global = true; 76 | 77 | module.exports = StartCommand; 78 | -------------------------------------------------------------------------------- /lib/commands/stop.js: -------------------------------------------------------------------------------- 1 | const Command = require('../command'); 2 | 3 | class StopCommand extends Command { 4 | static configureOptions(commandName, yargs, extensions) { 5 | const get = require('lodash/get'); 6 | const omit = require('lodash/omit'); 7 | 8 | extensions.forEach((extension) => { 9 | const options = get(extension, 'config.options.stop', false); 10 | if (!options) { 11 | return; 12 | } 13 | 14 | Object.assign(this.options, omit(options, Object.keys(this.options))); 15 | }); 16 | 17 | yargs = super.configureOptions(commandName, yargs, extensions); 18 | 19 | return yargs; 20 | } 21 | 22 | async run(argv) { 23 | const getInstance = require('../utils/get-instance'); 24 | const runOptions = {quiet: argv.quiet}; 25 | 26 | if (argv.all) { 27 | return this.stopAll(); 28 | } 29 | 30 | const instance = getInstance({ 31 | name: argv.name, 32 | system: this.system, 33 | command: 'stop', 34 | recurse: !argv.dir 35 | }); 36 | argv.local = instance.isLocal; 37 | const isRunning = await instance.isRunning(); 38 | 39 | if (!isRunning) { 40 | this.ui.log('Ghost is already stopped! For more information, run', 'ghost ls', 'green', 'cmd', true); 41 | return; 42 | } 43 | 44 | await this.ui.run(() => instance.stop(argv.disable), `Stopping Ghost: ${instance.name}`, runOptions); 45 | } 46 | 47 | async stopAll() { 48 | const instances = this.system.getAllInstances(true); 49 | for (const {name} of instances) { 50 | await this.ui.run(() => this.run({quiet: true, name}), `Stopping Ghost: ${name}`); 51 | } 52 | } 53 | } 54 | 55 | StopCommand.description = 'Stops an instance of Ghost'; 56 | StopCommand.params = '[name]'; 57 | StopCommand.options = { 58 | all: { 59 | alias: 'a', 60 | description: 'option to stop all running Ghost blogs', 61 | type: 'boolean' 62 | }, 63 | disable: { 64 | description: 'Disable restarting Ghost on server reboot (if the process manager supports it)', 65 | type: 'boolean' 66 | } 67 | }; 68 | StopCommand.global = true; 69 | 70 | module.exports = StopCommand; 71 | -------------------------------------------------------------------------------- /lib/commands/uninstall.js: -------------------------------------------------------------------------------- 1 | const Command = require('../command'); 2 | 3 | class UninstallCommand extends Command { 4 | async run(argv) { 5 | const fs = require('fs-extra'); 6 | const path = require('path'); 7 | 8 | const ghostUser = require('../utils/use-ghost-user'); 9 | 10 | if (!argv.force) { 11 | this.ui.log('WARNING: Running this command will delete all of your themes, images, data, any files related to this Ghost instance, and the contents of this folder!\n' + 12 | 'There is no going back!', 'yellow'); 13 | } 14 | 15 | const instance = this.system.getInstance(); 16 | 17 | const confirmed = await this.ui.confirm('Are you sure you want to do this?', argv.force); 18 | if (!confirmed) { 19 | return; 20 | } 21 | 22 | await this.ui.listr([{ 23 | title: 'Stopping Ghost', 24 | task: async () => { 25 | instance.loadRunningEnvironment(true); 26 | await instance.stop(true); 27 | }, 28 | skip: async () => { 29 | const isRunning = await instance.isRunning(); 30 | return !isRunning; 31 | } 32 | }, { 33 | title: 'Removing content folder', 34 | enabled: () => ghostUser.shouldUseGhostUser(path.join(instance.dir, 'content')), 35 | task: () => this.ui.sudo(`rm -rf ${path.join(instance.dir, 'content')}`) 36 | }, { 37 | title: 'Removing related configuration', 38 | task: () => { 39 | this.system.setEnvironment(!fs.existsSync(path.join(instance.dir, 'config.production.json'))); 40 | return this.system.hook('uninstall', instance); 41 | } 42 | }, { 43 | title: 'Removing Ghost installation', 44 | task: () => { 45 | this.system.removeInstance(instance); 46 | return Promise.all(fs.readdirSync('.').map(file => fs.remove(file))); 47 | } 48 | }]); 49 | } 50 | } 51 | 52 | UninstallCommand.description = 'Remove a Ghost instance and any related configuration files'; 53 | UninstallCommand.options = { 54 | force: { 55 | alias: 'f', 56 | description: 'Don\'t confirm deletion', 57 | type: 'boolean' 58 | } 59 | }; 60 | 61 | module.exports = UninstallCommand; 62 | -------------------------------------------------------------------------------- /lib/commands/version.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../command'); 3 | 4 | class VersionCommand extends Command { 5 | run() { 6 | const os = require('os'); 7 | const chalk = require('chalk'); 8 | 9 | const cliVersion = this.system.cliVersion; 10 | this.ui.log(`Ghost-CLI version: ${chalk.cyan(cliVersion)}`); 11 | 12 | const instance = this.system.getInstance(); 13 | // This will be false if we're not in a Ghost instance folder 14 | if (instance.version) { 15 | const dir = chalk.gray(`(at ${instance.dir.replace(os.homedir(), '~')})`); 16 | this.ui.log(`Ghost version: ${chalk.cyan(instance.version)} ${dir}`); 17 | } 18 | } 19 | } 20 | 21 | VersionCommand.description = 'Prints out Ghost-CLI version (and Ghost version if one exists)'; 22 | VersionCommand.global = true; 23 | VersionCommand.allowRoot = true; 24 | 25 | module.exports = VersionCommand; 26 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * This index file is used by extensions to require some of the various base classes 5 | * of the CLI. 6 | * 7 | * The reason for the additional logic here is because in various places in the CLI, 8 | * there are checks to see if a class extends one of the CLI core classes. Because 9 | * there could be multiple Ghost-CLI installs (since extensions have to require ghost-cli 10 | * in order to function), we ensure here that only the main version of each of these classes 11 | * is exported. 12 | */ 13 | 14 | const path = require('path'); 15 | const rootPath = path.resolve(path.dirname(require.main.filename), '../lib/index.js'); 16 | 17 | if (!require.main.filename.endsWith('ghost') || rootPath === __filename) { 18 | module.exports = { 19 | Command: require('./command'), 20 | ProcessManager: require('./process-manager'), 21 | Extension: require('./extension'), 22 | errors: require('./errors'), 23 | ui: require('./ui') 24 | }; 25 | } else { 26 | module.exports = require(rootPath); 27 | } 28 | -------------------------------------------------------------------------------- /lib/migrations.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | async function ensureFolder(context, folderName) { 4 | const ghostUser = require('./utils/use-ghost-user'); 5 | 6 | const contentDir = context.instance.config.get('paths.contentPath'); 7 | 8 | if (ghostUser.shouldUseGhostUser(contentDir)) { 9 | await context.ui.sudo(`mkdir -p ${path.resolve(contentDir, folderName)}`, {sudoArgs: '-E -u ghost'}); 10 | } else { 11 | const fs = require('fs-extra'); 12 | fs.ensureDirSync(path.resolve(contentDir, folderName)); 13 | } 14 | } 15 | 16 | async function ensureSettingsFolder(context) { 17 | await ensureFolder(context, 'settings'); 18 | } 19 | 20 | async function makeSqliteAbsolute({instance}) { 21 | const configs = await instance.getAvailableConfigs(); 22 | 23 | Object.values(configs).forEach((config) => { 24 | const currentFilename = config.get('database.connection.filename', null); 25 | if (!currentFilename || path.isAbsolute(currentFilename)) { 26 | return; 27 | } 28 | 29 | config.set('database.connection.filename', path.resolve(instance.dir, currentFilename)).save(); 30 | }); 31 | } 32 | 33 | async function ensureMediaFileAndPublicFolders(context) { 34 | await ensureFolder(context, 'media'); 35 | await ensureFolder(context, 'files'); 36 | await ensureFolder(context, 'public'); 37 | } 38 | 39 | module.exports = [{ 40 | before: '1.7.0', 41 | title: 'Create content/settings directory', 42 | task: ensureSettingsFolder 43 | }, { 44 | before: '1.14.1', 45 | title: 'Fix Sqlite DB path', 46 | task: makeSqliteAbsolute 47 | }, { 48 | before: '1.18.1', 49 | title: 'Create content/media, content/files and content/public directories', 50 | task: ensureMediaFileAndPublicFolders 51 | }]; 52 | -------------------------------------------------------------------------------- /lib/process-manager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const requiredMethods = [ 4 | 'start', 5 | 'stop', 6 | 'isRunning' 7 | ]; 8 | 9 | class ProcessManager { 10 | /** 11 | * Constructs the process manager. Process Managers get access to the UI and System instances 12 | * 13 | * @param {UI} ui UI instance 14 | * @param {System} system System 15 | * @param {Instance} instance Ghost instance 16 | */ 17 | constructor(ui, system, instance) { 18 | this.ui = ui; 19 | this.system = system; 20 | this.instance = instance; 21 | } 22 | 23 | /** 24 | * Method called to start the Ghost process 25 | * 26 | * @param {String} cwd Current working directory of Ghost instance 27 | * @param {String} environment Environment to start Ghost in 28 | * @return Promise|null 29 | */ 30 | async start() {} 31 | async stop() {} 32 | 33 | async restart(cwd, env) { 34 | await this.stop(cwd, env); 35 | await this.start(cwd, env); 36 | } 37 | 38 | /* istanbul ignore next */ 39 | success() { 40 | // Base implementation - noop 41 | } 42 | 43 | error(error) { 44 | // Base implementation - re-throw the error in case the 45 | // extension has no error method defined 46 | throw error; 47 | } 48 | 49 | async isRunning() { 50 | return false; 51 | } 52 | 53 | /** 54 | * General implementation of figuring out if the Ghost blog has started successfully. 55 | * 56 | * @returns {Promise} 57 | */ 58 | async ensureStarted(options) { 59 | const portPolling = require('./utils/port-polling'); 60 | const semver = require('semver'); 61 | 62 | options = Object.assign({ 63 | stopOnError: true, 64 | port: this.instance.config.get('server.port'), 65 | host: this.instance.config.get('server.host', 'localhost'), 66 | useNetServer: semver.major(this.instance.version) >= 2, 67 | useV4Boot: semver.major(this.instance.version) >= 4 68 | }, options || {}); 69 | 70 | try { 71 | await portPolling(this.ui, options); 72 | } catch (error) { 73 | if (options.stopOnError) { 74 | try { 75 | await this.stop(); 76 | } catch (e) { 77 | // ignore stop error 78 | } 79 | } 80 | 81 | throw error; 82 | } 83 | } 84 | 85 | // No-op base methods for enable/disable handling 86 | async isEnabled() { 87 | return false; 88 | } 89 | 90 | async enable() {} 91 | async disable() {} 92 | 93 | /** 94 | * This function checks if this process manager can be used on this system 95 | * 96 | * @return {Boolean} whether or not the process manager can be used 97 | */ 98 | static willRun() { 99 | // Base implementation - return true 100 | return true; 101 | } 102 | } 103 | 104 | function isValid(SubClass) { 105 | if (!(SubClass.prototype instanceof ProcessManager)) { 106 | return false; 107 | } 108 | 109 | const missing = requiredMethods.filter(method => !Object.prototype.hasOwnProperty.call(SubClass.prototype, method)); 110 | 111 | if (!missing.length) { 112 | return true; 113 | } 114 | 115 | return missing; 116 | } 117 | 118 | module.exports = ProcessManager; 119 | module.exports.isValid = isValid; 120 | -------------------------------------------------------------------------------- /lib/tasks/backup.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const zip = require('@tryghost/zip'); 3 | 4 | const debug = require('debug')('ghost-cli:backup'); 5 | const fs = require('fs-extra'); 6 | 7 | const ghostUser = require('../utils/use-ghost-user'); 8 | const {ProcessError} = require('../errors'); 9 | const {exportTask} = require('./import'); 10 | 11 | async function ensureBackupFolder(ui, instance) { 12 | const folderName = '../backup'; 13 | 14 | const contentDir = instance.config.get('paths.contentPath'); 15 | if (ghostUser.shouldUseGhostUser(contentDir)) { 16 | const {USER} = process.env; 17 | await ui.sudo(`mkdir -p ${path.resolve(contentDir, folderName)}`, {sudoArgs: `-E -u ${USER}`}); 18 | } else { 19 | fs.ensureDirSync(path.resolve(contentDir, folderName)); 20 | } 21 | } 22 | 23 | async function copyFiles(ui, instance, files) { 24 | const contentDir = instance.config.get('paths.contentPath'); 25 | const shouldUseSudo = ghostUser.shouldUseGhostUser(contentDir); 26 | 27 | for (const fileKey in files) { 28 | const filePath = path.join(instance.dir, fileKey); 29 | const fileExists = fs.existsSync(filePath); 30 | 31 | if (fileExists) { 32 | debug(`copying ${fileKey} to ${files[fileKey]}`); 33 | 34 | const destinationFilePath = path.join(instance.dir, files[fileKey]); 35 | if (shouldUseSudo) { 36 | await ui.sudo(`cp ${filePath} ${destinationFilePath}`); 37 | } else { 38 | await fs.copy(filePath, destinationFilePath); 39 | } 40 | } 41 | } 42 | 43 | if (shouldUseSudo) { 44 | await ui.sudo(`chown -R ghost:ghost ${contentDir}`); 45 | } 46 | } 47 | 48 | module.exports = async function (ui, instance) { 49 | const datetime = require('moment')().format('YYYY-MM-DD-HH-mm-ss'); 50 | const backupSuffix = `from-v${instance.version}-on-${datetime}`; 51 | 52 | // First we need to export the content into a JSON file & members into a CSV file 53 | const contentExportFile = `content-${backupSuffix}.json`; 54 | const membersExportFile = `members-${backupSuffix}.csv`; 55 | 56 | // Ensure the backup directory exists and has write permissions 57 | await ensureBackupFolder(ui, instance); 58 | 59 | // Generate backup files 60 | await exportTask(ui, instance, path.join(instance.dir, 'backup/', contentExportFile), path.join(instance.dir, 'backup/', membersExportFile)); 61 | 62 | // Next we need to copy `redirects.*` files from `data/` to `settings/` because 63 | // we're not going to backup `data/ 64 | const backupFiles = { 65 | 'content/data/redirects.json': 'content/settings/redirects.json', 66 | 'content/data/redirects.yaml': 'content/settings/redirects.yaml', 67 | [`backup/${contentExportFile}`]: `content/data/${contentExportFile}`, 68 | [`backup/${membersExportFile}`]: `content/data/${membersExportFile}` 69 | }; 70 | 71 | await copyFiles(ui, instance, backupFiles); 72 | 73 | // Finally we zip everything up into a nice little package 74 | const zipPath = path.join(process.cwd(), `backup-${backupSuffix}.zip`); 75 | 76 | try { 77 | await zip.compress(path.join(instance.dir, 'content/'), zipPath, {glob: `{data/${contentExportFile},data/${membersExportFile},files/**,images/**,media/**,settings/**,themes/**}`, ignore: 'themes/casper,themes/source'}); 78 | } catch (err) { 79 | throw new ProcessError(err); 80 | } 81 | 82 | return zipPath; 83 | }; 84 | -------------------------------------------------------------------------------- /lib/tasks/configure/get-prompts.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const {validate: validateUrl, ensureProtocol} = require('../../utils/url'); 3 | 4 | /** 5 | * Gets inquirer prompts based on passed argv and current config values 6 | * 7 | * @param {Config} config Config instane 8 | * @param {Object} argv Argv options 9 | */ 10 | module.exports = function getPrompts(config, argv, environment) { 11 | const prompts = []; 12 | 13 | if (!argv.url) { 14 | // Url command line option has not been supplied, add url config to prompts 15 | prompts.push({ 16 | type: 'input', 17 | name: 'url', 18 | message: 'Enter your blog URL:', 19 | default: argv.auto ? null : config.get('url', 'http://localhost:2368'), 20 | validate: validateUrl, 21 | filter: ensureProtocol 22 | }); 23 | } 24 | 25 | const db = argv.db || config.get('database.client'); 26 | 27 | if (!db || db !== 'sqlite3') { 28 | if (!argv.dbhost) { 29 | prompts.push({ 30 | type: 'input', 31 | name: 'dbhost', 32 | message: 'Enter your MySQL hostname:', 33 | default: config.get('database.connection.host', '127.0.0.1') 34 | }); 35 | } 36 | 37 | if (!argv.dbuser) { 38 | prompts.push({ 39 | type: 'input', 40 | name: 'dbuser', 41 | message: 'Enter your MySQL username:', 42 | default: config.get('database.connection.user'), 43 | validate: val => Boolean(val) || 'You must supply a MySQL username.' 44 | }); 45 | } 46 | 47 | if (!argv.dbpass) { 48 | prompts.push({ 49 | type: 'password', 50 | name: 'dbpass', 51 | message: 'Enter your MySQL password' + 52 | `${config.has('database.connection.password') ? ' (skip to keep current password)' : ''}:`, 53 | default: config.get('database.connection.password') 54 | }); 55 | } 56 | 57 | if (!argv.dbname) { 58 | const sanitizedDirName = path.basename(process.cwd()).replace(/[^a-zA-Z0-9_]+/g, '_'); 59 | const shortenedEnv = environment === 'development' ? 'dev' : 'prod'; 60 | prompts.push({ 61 | type: 'input', 62 | name: 'dbname', 63 | message: 'Enter your Ghost database name:', 64 | default: config.get('database.connection.database', `${sanitizedDirName}_${shortenedEnv}`), 65 | validate: val => !/[^a-zA-Z0-9_]/.test(val) || 'MySQL database names may consist of only alphanumeric characters and underscores.' 66 | }); 67 | } 68 | } 69 | 70 | return prompts; 71 | }; 72 | -------------------------------------------------------------------------------- /lib/tasks/configure/index.js: -------------------------------------------------------------------------------- 1 | const getPrompts = require('./get-prompts'); 2 | const parseOptions = require('./parse-options'); 3 | 4 | module.exports = function configure(ui, config, argv, environment, prompt = true) { 5 | if (!prompt || !argv.prompt) { 6 | return parseOptions(config, environment, argv); 7 | } 8 | 9 | const prompts = getPrompts(config, argv, environment); 10 | 11 | if (!prompts.length) { 12 | return parseOptions(config, environment, argv); 13 | } 14 | 15 | return ui.prompt(prompts).then((values) => { 16 | const db = values.dbhost ? {db: 'mysql'} : {}; 17 | const options = Object.assign({}, argv, db, values); 18 | 19 | return parseOptions(config, environment, options); 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /lib/tasks/configure/parse-options.js: -------------------------------------------------------------------------------- 1 | const url = require('url'); 2 | const Promise = require('bluebird'); 3 | const isFunction = require('lodash/isFunction'); 4 | const options = require('./options'); 5 | const {ConfigError} = require('../../errors'); 6 | 7 | function syncUrl(config, options) { 8 | if (options.url && options.port) { 9 | // If we have supplied both url and port options via args, we 10 | // don't want to override anything so just return 11 | return; 12 | } 13 | 14 | // Because the 'port' option can end up being different than the one supplied 15 | // in the URL itself, we want to make sure the port in the URL 16 | // (if one was there to begin with) is correct. 17 | const parsedUrl = url.parse(config.get('url')); 18 | if (parsedUrl.port && parsedUrl.port !== config.get('server.port', parsedUrl.port)) { 19 | parsedUrl.port = config.get('server.port'); 20 | // url.format won't take the new port unless 'parsedUrl.host' is undefined 21 | delete parsedUrl.host; 22 | 23 | config.set('url', url.format(parsedUrl)); 24 | } 25 | } 26 | 27 | /** 28 | * Parses options from argv or prompt, validates them, and sets them in the config 29 | * 30 | * @param {Config} config Config object 31 | * @param {String} environment Environment name 32 | * @param {Object} passedOptions Options passed via argv or prompts 33 | * @return {Promise} 34 | */ 35 | module.exports = function parseOptions(config, environment, passedOptions) { 36 | return Promise.each(Object.keys(options), (key) => { 37 | const { 38 | configPath = key, 39 | defaultValue, 40 | transform, 41 | validate = () => true 42 | } = options[key]; 43 | let value = passedOptions[key]; 44 | 45 | if (!value || !value.toString().length) { 46 | if (!defaultValue) { 47 | return Promise.resolve(); 48 | } 49 | 50 | const defaultOption = isFunction(defaultValue) ? defaultValue(config, environment) : defaultValue; 51 | 52 | return Promise.resolve(defaultOption).then((result) => { 53 | config.set(configPath, result); 54 | }); 55 | } 56 | 57 | if (value && transform) { 58 | value = transform(value); 59 | } 60 | 61 | return Promise.resolve(validate(value)).then((validated) => { 62 | if (validated !== true) { 63 | return Promise.reject(new ConfigError({ 64 | config: {[configPath]: value}, 65 | message: validated, 66 | environment 67 | })); 68 | } 69 | 70 | config.set(configPath, value); 71 | }); 72 | }).then(() => { 73 | syncUrl(config, passedOptions); 74 | config.save(); 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /lib/tasks/ensure-structure.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs-extra'); 3 | const path = require('path'); 4 | 5 | module.exports = function ensureStructure() { 6 | const cwd = process.cwd(); 7 | 8 | // Create `versions` directory 9 | fs.ensureDirSync(path.resolve(cwd, 'versions')); 10 | 11 | // Create `content` directory 12 | fs.ensureDirSync(path.resolve(cwd, 'content')); 13 | 14 | fs.ensureDirSync(path.resolve(cwd, 'content', 'apps')); 15 | fs.ensureDirSync(path.resolve(cwd, 'content', 'themes')); 16 | fs.ensureDirSync(path.resolve(cwd, 'content', 'data')); 17 | fs.ensureDirSync(path.resolve(cwd, 'content', 'images')); 18 | fs.ensureDirSync(path.resolve(cwd, 'content', 'logs')); 19 | fs.ensureDirSync(path.resolve(cwd, 'content', 'settings')); 20 | fs.ensureDirSync(path.resolve(cwd, 'content', 'media')); 21 | fs.ensureDirSync(path.resolve(cwd, 'content', 'files')); 22 | fs.ensureDirSync(path.resolve(cwd, 'content', 'public')); 23 | }; 24 | -------------------------------------------------------------------------------- /lib/tasks/import/index.js: -------------------------------------------------------------------------------- 1 | const {importTask, exportTask} = require('./tasks'); 2 | const parseExport = require('./parse-export'); 3 | 4 | module.exports = { 5 | importTask, 6 | exportTask, 7 | parseExport 8 | }; 9 | -------------------------------------------------------------------------------- /lib/tasks/import/parse-export.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const get = require('lodash/get'); 3 | const find = require('lodash/find'); 4 | const semver = require('semver'); 5 | 6 | const {SystemError} = require('../../errors'); 7 | 8 | const pre1xVersion = /^00[1-9]$/; 9 | 10 | /* eslint-disable camelcase */ 11 | function parse(content) { 12 | const data = get(content, 'db[0].data', content.data || null); 13 | /* istanbul ignore next */ 14 | const {id: role_id} = find(data.roles, {name: 'Owner'}) || {}; 15 | /* istanbul ignore next */ 16 | const {user_id} = find(data.roles_users, {role_id}) || {}; 17 | /* istanbul ignore next */ 18 | const {name, email} = find(data.users, {id: user_id}) || {}; 19 | /* istanbul ignore next */ 20 | const {value: blogTitle} = find(data.settings, {key: 'title'}) || {}; 21 | 22 | return {name, email, blogTitle}; 23 | } 24 | /* eslint-enable camelcase */ 25 | 26 | module.exports = function parseExport(file) { 27 | let content = {}; 28 | 29 | try { 30 | content = fs.readJsonSync(file); 31 | } catch (err) { 32 | throw new SystemError({ 33 | message: 'Import file not found or is not valid JSON', 34 | err 35 | }); 36 | } 37 | 38 | const version = get(content, 'db[0].meta.version', get(content, 'meta.version', null)); 39 | if (!version) { 40 | throw new SystemError('Unable to determine export version'); 41 | } 42 | 43 | const validVersion = pre1xVersion.test(version) || semver.valid(version); 44 | if (!validVersion) { 45 | throw new SystemError(`Unrecognized export version: ${version}`); 46 | } 47 | 48 | const data = parse(content); 49 | return { 50 | version: pre1xVersion.test(version) ? '0.11.14' : version, 51 | data 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /lib/tasks/import/tasks.js: -------------------------------------------------------------------------------- 1 | const validator = require('validator'); 2 | 3 | const {SystemError} = require('../../errors'); 4 | const parseExport = require('./parse-export'); 5 | const {isSetup, setup, runImport, downloadContentExport, downloadMembersExport} = require('./api'); 6 | 7 | const authPrompts = [{ 8 | type: 'string', 9 | name: 'username', 10 | message: 'Enter your Ghost administrator email address', 11 | validate: val => validator.isEmail(`${val}`) || 'You must specify a valid email' 12 | }, { 13 | type: 'password', 14 | name: 'password', 15 | message: 'Enter your Ghost administrator password', 16 | validate: val => validator.isLength(`${val}`, {min: 10}) || 'Password must be at least 10 characters long' 17 | }]; 18 | 19 | async function importTask(ui, instance, exportFile) { 20 | const {data} = parseExport(exportFile); 21 | const url = instance.config.get('url'); 22 | 23 | let prompts = authPrompts; 24 | 25 | const blogIsSetup = await isSetup(instance.version, url); 26 | if (!blogIsSetup) { 27 | prompts = authPrompts.slice(1); 28 | } 29 | 30 | const {username, password} = await ui.prompt(prompts); 31 | const importUsername = username || data.email; 32 | 33 | return ui.listr([{ 34 | title: 'Running blog setup', 35 | task: () => setup(instance.version, url, {...data, password}), 36 | enabled: () => !blogIsSetup 37 | }, { 38 | title: 'Running blog import', 39 | task: () => runImport(instance.version, url, {username: importUsername, password}, exportFile) 40 | }], false); 41 | } 42 | 43 | async function exportTask(ui, instance, contentFile, membersFile) { 44 | const url = instance.config.get('url'); 45 | 46 | const blogIsSetup = await isSetup(instance.version, url); 47 | if (!blogIsSetup) { 48 | throw new SystemError('Cannot export content from a blog that hasn\'t been set up.'); 49 | } 50 | 51 | const authData = await ui.prompt(authPrompts); 52 | 53 | await downloadContentExport(instance.version, url, authData, contentFile); 54 | 55 | if (membersFile) { 56 | await downloadMembersExport(instance.version, url, authData, membersFile); 57 | } 58 | } 59 | 60 | module.exports = { 61 | importTask, 62 | exportTask 63 | }; 64 | -------------------------------------------------------------------------------- /lib/tasks/linux.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const execa = require('execa'); 4 | const path = require('path'); 5 | 6 | module.exports = function linuxSetupTask({ui, instance}) { 7 | let userExists = false; 8 | 9 | try { 10 | execa.shellSync('id ghost'); 11 | userExists = true; 12 | } catch (e) { 13 | // an error here essentially means that the user doesn't exist 14 | // so we don't need to do any additional checking really 15 | } 16 | 17 | return ui.listr([{ 18 | title: 'Creating "ghost" system user', 19 | skip: () => userExists, 20 | task: () => ui.sudo('useradd --system --user-group ghost') 21 | }, { 22 | title: 'Giving "ghost" user ownership of the /content/ directory', 23 | task: () => ui.sudo(`chown -R ghost:ghost ${path.join(instance.dir, 'content')}`) 24 | }], false); 25 | }; 26 | 27 | -------------------------------------------------------------------------------- /lib/tasks/major-update/data.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async function getData({dir, database, versionFolder, version}) { 4 | const path = require('path'); 5 | const semver = require('semver'); 6 | const {CliError} = require('../../errors'); 7 | 8 | if (!dir) { 9 | throw new CliError({message: '`dir` is required.'}); 10 | } 11 | 12 | if (!database) { 13 | throw new CliError({message: '`database` is required.'}); 14 | } 15 | 16 | const knexPath = path.resolve(dir, versionFolder, 'node_modules', 'knex'); 17 | const gscanPath = path.resolve(dir, versionFolder, 'node_modules', 'gscan'); 18 | const mysql2Path = path.resolve(dir, versionFolder, 'node_modules', 'mysql2'); 19 | 20 | const knex = require(knexPath); 21 | const gscan = require(gscanPath); 22 | 23 | try { 24 | // If we're using MySQL, check to see if the `mysql2` lib exists because we 25 | // need to change the `client` to maintain backwards compatibility with config 26 | if (database.client === 'mysql') { 27 | require(mysql2Path); 28 | database.client = 'mysql2'; 29 | } 30 | } catch (err) { 31 | // `mysql2` doesn't exist, so we can stay with `mysql` 32 | } 33 | 34 | const connection = knex({...database, useNullAsDefault: true}); 35 | const query = async (sql) => { 36 | const response = await connection.raw(sql); 37 | 38 | if (['mysql', 'mysql2'].includes(database.client)) { 39 | return response[0][0]; 40 | } 41 | 42 | return response[0]; 43 | }; 44 | 45 | let themeFolder = path.resolve(dir, 'content', 'themes'); 46 | 47 | try { 48 | const {value: activeTheme} = await query('SELECT value FROM settings WHERE `key` = "active_theme";'); 49 | 50 | // CASE: use casper from v2 folder, otherwise we are validating the old casper 51 | if (activeTheme === 'casper') { 52 | themeFolder = path.resolve(dir, versionFolder, 'content', 'themes'); 53 | } 54 | 55 | const checkVersion = `v${semver.major(version)}`; 56 | const report = await gscan.check(path.resolve(themeFolder, activeTheme), {checkVersion}); 57 | 58 | return { 59 | gscanReport: gscan.format(report, {sortByFiles: true, checkVersion}), 60 | demoPost: await query('SELECT uuid FROM posts WHERE `slug` = "v2-demo-post";') 61 | }; 62 | } finally { 63 | await connection.destroy(); 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /lib/tasks/major-update/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = require('./ui'); 3 | -------------------------------------------------------------------------------- /lib/tasks/release-notes.js: -------------------------------------------------------------------------------- 1 | module.exports = async (ctx, task) => { 2 | const got = require('got'); 3 | let response; 4 | 5 | try { 6 | response = await got.get('https://api.github.com/repos/TryGhost/Ghost/releases', {json: true, timeout: 5000}); 7 | } catch (err) { 8 | task.title = 'Unable to fetch release notes'; 9 | return; 10 | } 11 | 12 | const relevantNotes = response.body.find(note => note.tag_name.replace('v', '') === ctx.version); 13 | 14 | if (!relevantNotes) { 15 | task.title = 'Release notes were not found'; 16 | return; 17 | } 18 | 19 | task.title = 'Fetched release notes'; 20 | ctx.ui.log(`\n# ${relevantNotes.name}\n\n${relevantNotes.body}\n`, 'green'); 21 | }; 22 | -------------------------------------------------------------------------------- /lib/utils/check-root-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | const chalk = require('chalk'); 5 | const fs = require('fs'); 6 | 7 | const isRootInstall = function isRootInstall() { 8 | const path = require('path'); 9 | const cliFile = path.join(process.cwd(), '.ghost-cli'); 10 | 11 | return fs.existsSync(cliFile) && fs.statSync(cliFile).uid === 0; 12 | }; 13 | 14 | function checkRootUser(command) { 15 | const allowedCommands = ['stop', 'start', 'restart']; 16 | const isOneClickInstall = fs.existsSync('/root/.digitalocean_password'); 17 | 18 | if (os.platform() !== 'linux' || process.getuid() !== 0) { 19 | return; 20 | } 21 | 22 | if (isOneClickInstall) { 23 | // We have a Digitalocean one click installation 24 | if (!isRootInstall()) { 25 | // CASE: the user uses either the new DO image, where installations are following our setup guid (aka not-root), 26 | // or the user followed the fix root user guide already, but the user uses root to run the command 27 | console.error(`${chalk.yellow('You can\'t run commands as the \'root\' user.')} 28 | Switch to your regular user, or create a new user with regular account privileges and use this user to run 'ghost ${command}'. 29 | For more information, see ${chalk.underline.green('https://ghost.org/docs/install/ubuntu/#create-a-new-user')}.\n`); 30 | } else { 31 | // CASE: the ghost installation folder is owned by root. The user needs to migrate the installation 32 | // to a non-root and follow the instructions. 33 | console.error(`${chalk.yellow('It looks like you\'re using using the DigitalOcean One-Click install.')} 34 | You need to create a user with regular account privileges and migrate your installation to this user. 35 | There's a guide to fixing your setup here: ${chalk.underline.green('https://ghost.org/faq/root-user-fix/')}.\n`); 36 | } 37 | 38 | // TODO: remove this 4 versions after 1.5.0 39 | if (allowedCommands.includes(command)) { 40 | return; 41 | } 42 | } else if (isRootInstall()) { 43 | console.error(`${chalk.yellow('It looks like Ghost was installed using the root user.')} 44 | You need to create a user with regular account privileges and migrate your installation to this user. 45 | There's a guide to fixing your setup here: ${chalk.underline.green('https://ghost.org/faq/root-user-fix/')}.\n`); 46 | 47 | // TODO: remove this 4 versions after 1.5.0 48 | if (allowedCommands.includes(command)) { 49 | return; 50 | } 51 | } else { 52 | console.error(`${chalk.yellow('You can\'t run commands as the \'root\' user.')} 53 | Switch to your regular user, or create a new user with regular account privileges and use this user to run 'ghost ${command}'. 54 | For more information, see ${chalk.underline.green('https://ghost.org/docs/install/ubuntu/#create-a-new-user')}.\n`); 55 | } 56 | 57 | process.exit(1); 58 | } 59 | 60 | module.exports = checkRootUser; 61 | -------------------------------------------------------------------------------- /lib/utils/check-valid-install.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs-extra'); 4 | const path = require('path'); 5 | const chalk = require('chalk'); 6 | 7 | /** 8 | * Checks if the cwd is a valid ghost installation. Also checks if the current 9 | * directory contains a development install of ghost (e.g. a git clone), and outputs 10 | * a helpful error message if so. 11 | * 12 | * @param {string} name Name of command, will be output if there's an issue 13 | * @param {boolean} dir directory to check 14 | * @returns {boolean | null} If the directory contains a valid installation. Returns null for non-deterministic results 15 | */ 16 | function checkValidInstall(name, dir = process.cwd()) { 17 | /* 18 | * CASE: a `config.js` file exists, which means this is a LTS installation 19 | * which we don't support in the CLI 20 | */ 21 | if (fs.existsSync(path.join(dir, 'config.js'))) { 22 | console.error(`${chalk.yellow('Ghost-CLI only works with Ghost versions >= 1.0.0.')} 23 | If you are trying to upgrade Ghost LTS to 1.0.0, refer to ${chalk.blue.underline('https://ghost.org/faq/upgrade-to-ghost-1-0/')}. 24 | Otherwise, run \`ghost ${name}\` again within a valid Ghost installation.`); 25 | 26 | return false; 27 | } 28 | 29 | /* 30 | * We assume it's a Ghost development install if 3 things are true: 31 | * 1) package.json exists 32 | * 2) package.json "name" field is "ghost" 33 | * 3) Gruntfile.js exists 34 | */ 35 | if (fs.existsSync(path.join(dir, 'package.json')) && 36 | fs.readJsonSync(path.join(dir, 'package.json')).name === 'ghost' && 37 | fs.existsSync(path.join(dir, 'Gruntfile.js')) 38 | ) { 39 | console.error(`${chalk.yellow('Ghost-CLI commands do not work inside of a git clone, archive download or with Ghost <1.0.0.')} 40 | Perhaps you meant \`grunt ${name}\`? 41 | Otherwise, run \`ghost ${name}\` again within a valid Ghost installation.`); 42 | 43 | return false; 44 | } 45 | 46 | /* 47 | * Assume it's not a valid CLI install if the `.ghost-cli` file doesn't exist 48 | */ 49 | if (!fs.existsSync(path.join(dir, '.ghost-cli'))) { 50 | return null; 51 | } 52 | 53 | return true; 54 | } 55 | 56 | module.exports = checkValidInstall; 57 | -------------------------------------------------------------------------------- /lib/utils/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const isPlainObject = require('lodash/isPlainObject'); 3 | const _get = require('lodash/get'); 4 | const _set = require('lodash/set'); 5 | const _has = require('lodash/has'); 6 | const fs = require('fs-extra'); 7 | 8 | /** 9 | * Config class. Basic wrapper around a json file, but handles 10 | * nested properties editing, default values, and saving 11 | * 12 | * @class Config 13 | */ 14 | class Config { 15 | /** 16 | * Constructs the config instance 17 | * 18 | * @param {string} filename Filename to load 19 | */ 20 | constructor(filename) { 21 | if (!filename) { 22 | throw new Error('Config file not specified.'); 23 | } 24 | 25 | this.file = filename; 26 | this.values = Config.exists(this.file) || {}; 27 | } 28 | 29 | /** 30 | * Gets a value from the config file. Uses the lodash `get` method 31 | * 32 | * @param {string} key Key to get 33 | * @param {any} defaultValue Value to return if config value doesn't exist 34 | * @return {any} Value in the config file if it exists, otherwise null 35 | * 36 | * @method get 37 | * @public 38 | */ 39 | get(key, defaultValue) { 40 | return _get(this.values, key, defaultValue); 41 | } 42 | 43 | /** 44 | * Sets a value in the config. 45 | * If 'value' is null, removes the key from the config 46 | * 47 | * @param {string} key Key to set 48 | * @param {any} value Value to set at `key` 49 | * @return Config This config instance 50 | * 51 | * @method get 52 | * @public 53 | */ 54 | set(key, value) { 55 | if (isPlainObject(key)) { 56 | Object.assign(this.values, key); 57 | return this; 58 | } 59 | 60 | // Setting a value to null removes it from the config 61 | if (value === null) { 62 | delete this.values[key]; 63 | return this; 64 | } 65 | 66 | _set(this.values, key, value); 67 | return this; 68 | } 69 | 70 | /** 71 | * Checks if a value exists for 'key' in the config 72 | * 73 | * @param {string} key Key to check 74 | * @return bool Whether or not the config value exists 75 | * 76 | * @method has 77 | * @public 78 | */ 79 | has(key) { 80 | return _has(this.values, key); 81 | } 82 | 83 | /** 84 | * Saves the config file to disk 85 | * 86 | * @method save 87 | * @public 88 | */ 89 | save() { 90 | // Write with spaces to make the files easier to edit manually 91 | fs.writeJsonSync(this.file, this.values, {spaces: 2}); 92 | return this; 93 | } 94 | 95 | /** 96 | * Checks whether or not a config file exists 97 | * @param {string} filename Filename to check 98 | * 99 | * @static 100 | * @method exists 101 | * @public 102 | */ 103 | static exists(filename) { 104 | try { 105 | const result = fs.readJsonSync(filename); 106 | return result; 107 | } catch (e) { 108 | return false; 109 | } 110 | } 111 | } 112 | 113 | module.exports = Config; 114 | -------------------------------------------------------------------------------- /lib/utils/deprecation-checks.js: -------------------------------------------------------------------------------- 1 | const semver = require('semver'); 2 | const boxen = require('boxen'); 3 | const chalk = require('chalk'); 4 | const debug = require('debug')('ghost-cli:deprecation-checks'); 5 | const Promise = require('bluebird'); 6 | 7 | const nodeDeprecated = () => boxen(chalk.yellow(` 8 | The current Node.js version (${process.versions.node}) has reached end-of-life status. 9 | Ghost-CLI will drop support for this Node.js version in an upcoming release, please update your Node.js version. 10 | See ${chalk.cyan('https://ghost.org/docs/faq/node-versions/')}. 11 | `.trim()), {borderColor: 'yellow', align: 'center'}); 12 | 13 | const ghostDeprecated = () => boxen(chalk.yellow(` 14 | Ghost 3.x and below have reached end-of-life status. 15 | End-of-life software does not receive bug or security fixes, please update your Ghost version. 16 | See ${chalk.cyan('https://ghost.org/docs/faq/major-versions-lts/')}. 17 | `.trim()), {borderColor: 'yellow', align: 'center'}); 18 | 19 | const databaseDeprecated = () => boxen(chalk.yellow(` 20 | Warning: MySQL 8 will be the required database in the next major release of Ghost. 21 | Make sure your database is up to date to ensure forwards compatibility. 22 | `.trim()), {borderColor: 'yellow', align: 'center'}); 23 | 24 | async function deprecationChecks(ui, system) { 25 | if (semver.lt(process.versions.node, '12.0.0')) { 26 | ui.log(nodeDeprecated()); 27 | } 28 | 29 | const allInstances = await system.getAllInstances(false); 30 | 31 | const showGhostDeprecation = allInstances 32 | .some(instance => instance.version && semver.lt(instance.version, '4.0.0')); 33 | 34 | if (showGhostDeprecation) { 35 | ui.log(ghostDeprecated()); 36 | } 37 | 38 | try { 39 | const showDatabaseDeprecation = (await Promise.mapSeries(allInstances, async (instance) => { 40 | instance.checkEnvironment(); 41 | 42 | const isProduction = instance.system.production; 43 | const databaseClient = instance.config.get('database.client'); 44 | 45 | if (isProduction && databaseClient === 'sqlite3') { // SQLite is only supported in development 46 | return true; 47 | } else if (databaseClient === 'mysql') { 48 | const mysqlExtension = instance.system._extensions.filter(e => e.pkg.name === 'ghost-cli-mysql'); 49 | 50 | // This shouldn't happen, but still 51 | if (!mysqlExtension.length) { 52 | return; 53 | } 54 | 55 | const dbconfig = instance.config.get('database.connection'); 56 | const mysqlIsDeprecated = await mysqlExtension[0].isDeprecated(dbconfig); 57 | return mysqlIsDeprecated; 58 | } 59 | })) 60 | .filter(Boolean) 61 | .length; 62 | 63 | if (showDatabaseDeprecation) { 64 | ui.log(databaseDeprecated()); 65 | } 66 | } catch (err) { 67 | debug('Unable to fetch DB deprecations', err); 68 | } 69 | } 70 | 71 | module.exports = deprecationChecks; 72 | -------------------------------------------------------------------------------- /lib/utils/dir-is-empty.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const debugLogRegex = /^ghost-cli-debug-.*\.log$/i; 4 | const importantDotfiles = ['.ghost-cli']; 5 | 6 | module.exports = function dirIsEmpty(dir) { 7 | const files = fs.readdirSync(dir); 8 | 9 | if (!files.length) { 10 | return true; 11 | } 12 | 13 | return files.every(file => file.match(debugLogRegex) || (file.startsWith('.') && !importantDotfiles.includes(file))); 14 | }; 15 | -------------------------------------------------------------------------------- /lib/utils/find-extensions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const findPlugins = require('find-plugins'); 5 | const createDebug = require('debug'); 6 | 7 | const debug = createDebug('ghost-cli:find-extensions'); 8 | 9 | const localExtensions = [ 10 | 'mysql', 11 | 'nginx', 12 | 'systemd' 13 | ].map(local => path.join(__dirname, '..', '..', 'extensions', local)); 14 | 15 | /** 16 | * Finds available extensions in the system. Checks both the local CLI install 17 | * (for internal extensions) as well as the npm root, as determined by `npm root -g` 18 | * 19 | * @return Array array containing the package.json and dir of any found extensions 20 | */ 21 | module.exports = function findExtensions() { 22 | const npmRoot = require('global-modules'); 23 | debug(`searching for extensions in ${npmRoot}`); 24 | 25 | return findPlugins({ 26 | keyword: 'ghost-cli-extension', 27 | configName: 'ghost-cli', 28 | include: localExtensions, 29 | dir: fs.existsSync(npmRoot) ? npmRoot : process.cwd(), 30 | scanAllDirs: true, 31 | sort: true 32 | }).map((ext) => { 33 | // We do some additional stuff here to make it easier to 34 | // use this in other places 35 | if (ext.pkg['ghost-cli']) { 36 | ext.config = ext.pkg['ghost-cli']; 37 | } 38 | 39 | // Get the name from either the `ghost-cli` config property, or the 40 | // name of the package 41 | ext.name = (ext.config && ext.config.name) || ext.pkg.name; 42 | 43 | return ext; 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /lib/utils/find-valid-install.js: -------------------------------------------------------------------------------- 1 | const {dirname} = require('path'); 2 | const debug = require('debug')('ghost-cli:find-instance'); 3 | const chalk = require('chalk'); 4 | const checkValidInstall = require('./check-valid-install'); 5 | 6 | const isRoot = dir => dirname(dir) === dir; 7 | 8 | const die = (name) => { 9 | console.error(`${chalk.yellow('Working directory is not a recognisable Ghost installation.')} 10 | Run \`ghost ${name}\` again within a folder where Ghost was installed with Ghost-CLI.`); 11 | process.exit(1); 12 | }; 13 | 14 | function findValidInstallation(name = '', recursive = false) { 15 | let dir = process.cwd(); 16 | 17 | while (!isRoot(dir)) { 18 | debug(`Checking valid install: ${dir}`); 19 | const isValidInstall = checkValidInstall(name, dir); 20 | 21 | if (isValidInstall) { 22 | process.chdir(dir); 23 | return; 24 | } 25 | 26 | if (isValidInstall === false) { 27 | process.exit(1); 28 | } 29 | 30 | if (!recursive) { 31 | die(name); 32 | return; 33 | } 34 | 35 | debug(`Going up...`); 36 | dir = dirname(dir); 37 | } 38 | 39 | die(name); 40 | } 41 | 42 | module.exports = findValidInstallation; 43 | -------------------------------------------------------------------------------- /lib/utils/get-instance.js: -------------------------------------------------------------------------------- 1 | const {SystemError} = require('../errors'); 2 | const findValidInstallation = require('./find-valid-install'); 3 | 4 | /** 5 | * @param {object} options 6 | * @param {string} options.name 7 | * @param {import('../system.js')} options.system 8 | * @param {string} options.command 9 | * @param {boolean} options.recurse 10 | */ 11 | function getInstance({ 12 | name: instanceName, 13 | system, 14 | command: commandName, 15 | recurse 16 | }) { 17 | if (instanceName) { 18 | const instance = system.getInstance(instanceName); 19 | if (!instance) { 20 | throw new SystemError(`Ghost instance '${instanceName}' does not exist`); 21 | } 22 | 23 | process.chdir(instance.dir); 24 | return instance; 25 | } 26 | 27 | findValidInstallation(commandName, recurse); 28 | return system.getInstance(); 29 | } 30 | 31 | module.exports = getInstance; 32 | -------------------------------------------------------------------------------- /lib/utils/get-proxy-agent.js: -------------------------------------------------------------------------------- 1 | const {getProxyForUrl} = require('proxy-from-env'); 2 | const NPM_REGISTRY = 'https://registry.npmjs.org/'; 3 | 4 | let proxyAgent = false; 5 | 6 | module.exports = function getProxyAgent() { 7 | // Initialize Proxy Agent for proxy support if needed 8 | const proxyAddress = getProxyForUrl(NPM_REGISTRY); 9 | if (proxyAddress && !proxyAgent) { 10 | const HttpsProxyAgent = require('https-proxy-agent'); 11 | proxyAgent = new HttpsProxyAgent(proxyAddress); 12 | } 13 | 14 | return proxyAgent; 15 | }; 16 | -------------------------------------------------------------------------------- /lib/utils/needed-migrations.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const semver = require('semver'); 3 | const flatten = require('lodash/flatten'); 4 | 5 | const coreMigrations = require('../migrations'); 6 | 7 | /** 8 | * @param {String} originalVersion Original version that installed the instance 9 | * @param {String} currentVersion Current CLI version 10 | * @param {Array} extensionMigrations Migrations returned from extensions 11 | */ 12 | module.exports = function parseNeededMigrations(originalVersion, currentVersion, extensionMigrations) { 13 | const migrations = coreMigrations.concat(flatten(extensionMigrations).filter(Boolean)); 14 | 15 | return migrations.filter((migration) => { 16 | // If the migration has a `before` property defined and the original CLI version is before it 17 | if (migration.before && semver.gte(originalVersion, migration.before)) { 18 | return false; 19 | } 20 | 21 | return true; 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /lib/utils/pre-checks.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const os = require('os'); 3 | const path = require('path'); 4 | const semver = require('semver'); 5 | const latestVersion = require('latest-version'); 6 | const pkg = require('../../package.json'); 7 | const getProxyAgent = require('./get-proxy-agent'); 8 | 9 | const configstore = path.join(os.homedir(), '.config'); 10 | 11 | async function updateCheck({ui}) { 12 | const latest = await latestVersion(pkg.name, { 13 | agent: getProxyAgent() 14 | }); 15 | 16 | if (semver.lt(pkg.version, latest)) { 17 | const chalk = require('chalk'); 18 | 19 | ui.log( 20 | 'You are running an outdated version of Ghost-CLI.\n' + 21 | 'It is recommended that you upgrade before continuing.\n' + 22 | `Run ${chalk.cyan('`npm install -g ghost-cli@latest`')} to upgrade.\n`, 23 | 'yellow' 24 | ); 25 | } 26 | } 27 | 28 | async function checkConfigPerms({ui}) { 29 | const stats = await fs.lstat(configstore); 30 | 31 | if (stats.uid === process.getuid() && stats.gid === process.getgid()) { 32 | return; 33 | } 34 | 35 | const {USER} = process.env; 36 | await ui.sudo(`chown -R ${USER}:${USER} ${configstore}`); 37 | } 38 | 39 | /** 40 | * Checks if a version update is available 41 | * @param {UI} ui ui instance 42 | * @param {System} system System instance 43 | */ 44 | module.exports = function preChecks(ui, system) { 45 | if (process.env.GHOST_CLI_PRE_CHECKS === 'false') { 46 | return; 47 | } 48 | 49 | const tasks = [{ 50 | title: 'Checking for Ghost-CLI updates', 51 | task: updateCheck 52 | }, { 53 | title: 'Ensuring correct ~/.config folder ownership', 54 | task: checkConfigPerms, 55 | enabled: () => system.platform.linux && fs.existsSync(configstore) 56 | }]; 57 | 58 | return ui.listr(tasks, {ui}, {clearOnSuccess: true}); 59 | }; 60 | 61 | // exports for unit testing 62 | module.exports.updateCheck = updateCheck; 63 | module.exports.checkConfigPerms = checkConfigPerms; 64 | -------------------------------------------------------------------------------- /lib/utils/url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {isURL} = require('validator'); 4 | const endsWithGhost = /\/ghost\/?$/i; 5 | 6 | const validate = function validateURL(url) { 7 | const isValidURL = isURL(url, {require_protocol: true}); 8 | if (!isValidURL) { 9 | return 'Invalid domain. Your domain should include a protocol and a TLD, E.g. http://my-ghost-blog.com'; 10 | } 11 | 12 | return (!endsWithGhost.test(url)) || 'Ghost doesn\'t support running in a path that ends with `ghost`'; 13 | }; 14 | 15 | const isCustomDomain = function isCustomDomain(input) { 16 | // If this is localhost or an IP, it's not a custom domain 17 | return !(/localhost/.test(input) || /((\d){1,3}\.){3}(\d){1,3}/.test(input)); 18 | }; 19 | 20 | const ensureProtocol = function ensureProtocol(input) { 21 | let output = input.toLowerCase().trim(); 22 | let proto = ''; 23 | 24 | if (!/^http/.test(output)) { 25 | // Custom domains should always be HTTPS, localhost/IP should be HTTP 26 | proto = isCustomDomain(output) ? 'https:' : 'http:'; 27 | 28 | // If this doesn't start with 2 slashes, add them 29 | if (!/^\/\//.test(output)) { 30 | proto = proto + '//'; 31 | } 32 | } 33 | 34 | return proto + output; 35 | }; 36 | 37 | module.exports = { 38 | validate, 39 | isCustomDomain, 40 | ensureProtocol 41 | }; 42 | -------------------------------------------------------------------------------- /lib/utils/use-ghost-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const os = require('os'); 4 | const execa = require('execa'); 5 | 6 | const getGhostUid = function getGhostUid() { 7 | if (os.platform() !== 'linux') { 8 | return false; 9 | } 10 | 11 | let ghostuid; let ghostgid; 12 | 13 | try { 14 | ghostuid = execa.shellSync('id -u ghost').stdout; 15 | ghostgid = execa.shellSync('id -g ghost').stdout; 16 | } catch (e) { 17 | // CASE: the ghost user doesn't exist, hence can't be used 18 | // We just return false and not doing anything with the error, 19 | // as it would either mean, that the user doesn't exist (this 20 | // is exactly what we want), or the command is not known on a 21 | // Linux system. 22 | return false; 23 | } 24 | 25 | ghostuid = parseInt(ghostuid); 26 | ghostgid = parseInt(ghostgid); 27 | 28 | return { 29 | uid: ghostuid, 30 | gid: ghostgid 31 | }; 32 | }; 33 | 34 | const shouldUseGhostUser = function shouldUseGhostUser(contentDir) { 35 | if (os.platform() !== 'linux') { 36 | return false; 37 | } 38 | 39 | // get the ghost uid and gid 40 | const ghostUser = getGhostUid(); 41 | 42 | if (!ghostUser) { 43 | return false; 44 | } 45 | 46 | const stats = fs.lstatSync(contentDir); 47 | 48 | if (stats.uid !== ghostUser.uid && stats.gid !== ghostUser.gid) { 49 | return false; 50 | } 51 | 52 | return process.getuid() !== ghostUser.uid; 53 | }; 54 | 55 | module.exports = { 56 | getGhostUid: getGhostUid, 57 | shouldUseGhostUser: shouldUseGhostUser 58 | }; 59 | -------------------------------------------------------------------------------- /lib/utils/yarn.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const execa = require('execa'); 3 | const {Observable} = require('rxjs'); 4 | const {ProcessError} = require('../errors'); 5 | 6 | /** 7 | * Runs a yarn command. Can return an Observer which allows 8 | * listr to output the current status of yarn 9 | */ 10 | module.exports = function yarn(yarnArgs, options) { 11 | options = options || {}; 12 | 13 | const observe = options.observe || false; 14 | delete options.observe; 15 | 16 | const verbose = options.verbose || false; 17 | delete options.verbose; 18 | 19 | yarnArgs = yarnArgs || []; 20 | 21 | if (verbose) { 22 | yarnArgs.push('--verbose'); 23 | } 24 | 25 | const execaOpts = {...options, preferLocal: true, localDir: __dirname}; 26 | const cp = execa('yarn', yarnArgs, execaOpts); 27 | 28 | if (!observe) { 29 | // execa augments the error object with 30 | // some other properties, so we just pass 31 | // the entire error object in as options to 32 | // the ProcessError 33 | return cp.catch(error => Promise.reject(new ProcessError(error))); 34 | } 35 | 36 | return new Observable((observer) => { 37 | const onData = data => observer.next(data.replace(/\n$/, '')); 38 | 39 | cp.stdout.setEncoding('utf8'); 40 | cp.stdout.on('data', onData); 41 | 42 | if (verbose) { 43 | cp.stderr.setEncoding('utf8'); 44 | cp.stderr.on('data', onData); 45 | } 46 | 47 | cp.then(() => { 48 | observer.complete(); 49 | }).catch((error) => { 50 | // execa augments the error object with 51 | // some other properties, so we just pass 52 | // the entire error object in as options to 53 | // the ProcessError 54 | observer.error(new ProcessError(error)); 55 | }); 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghost-cli", 3 | "version": "1.27.0", 4 | "description": "CLI Tool for installing & updating Ghost", 5 | "author": "Ghost Foundation", 6 | "homepage": "https://ghost.org", 7 | "keywords": [ 8 | "ghost", 9 | "cli" 10 | ], 11 | "files": [ 12 | "bin", 13 | "lib", 14 | "extensions", 15 | "yarn.lock" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/TryGhost/Ghost-CLI/" 20 | }, 21 | "bugs": "https://github.com/TryGhost/Ghost-CLI/issues", 22 | "contributors": "https://github.com/TryGhost/Ghost-CLI/graphs/contributors", 23 | "license": "MIT", 24 | "main": "lib/index.js", 25 | "bin": { 26 | "ghost": "./bin/ghost" 27 | }, 28 | "scripts": { 29 | "coverage": "nyc report --reporter=text-lcov | coveralls", 30 | "lint": "eslint bin/* lib test extensions", 31 | "test": "nyc --reporter=html --reporter=text mocha -t 5000 --recursive test/unit extensions/**/test", 32 | "posttest": "yarn lint", 33 | "ship": "f() { STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then standard-version $@ && git push --follow-tags; fi; }; f" 34 | }, 35 | "nyc": { 36 | "exclude": [ 37 | "**/*-spec.js", 38 | "test" 39 | ] 40 | }, 41 | "engines": { 42 | "node": "^12.22.1 || ^14.17.0 || ^16.13.0 || ^18.0.0 || ^20.11.1 || ^22.11.0" 43 | }, 44 | "preferGlobal": true, 45 | "dependencies": { 46 | "@tryghost/zip": "^1.1.25", 47 | "abbrev": "3.0.1", 48 | "bluebird": "3.7.2", 49 | "boxen": "5.1.2", 50 | "chalk": "4.1.2", 51 | "cli-table3": "0.5.0", 52 | "debug": "4.3.4", 53 | "decompress": "4.2.1", 54 | "download": "8.0.0", 55 | "execa": "1.0.0", 56 | "find-plugins": "1.1.7", 57 | "fkill": "7.2.1", 58 | "form-data": "3.0.3", 59 | "fs-extra": "11.1.1", 60 | "generate-password": "1.7.1", 61 | "global-modules": "2.0.0", 62 | "got": "9.6.0", 63 | "https-proxy-agent": "5.0.1", 64 | "ini": "2.0.0", 65 | "inquirer": "7.3.3", 66 | "is-running": "2.1.0", 67 | "latest-version": "5.1.0", 68 | "listr": "0.14.3", 69 | "lodash": "4.17.21", 70 | "log-symbols": "4.1.0", 71 | "moment": "2.27.0", 72 | "mysql2": "2.3.3", 73 | "ora": "3.4.0", 74 | "package-json": "7.0.0", 75 | "path-is-root": "0.1.0", 76 | "portfinder": "1.0.35", 77 | "prettyjson": "1.2.5", 78 | "proxy-from-env": "1.1.0", 79 | "read-last-lines": "1.8.0", 80 | "replace-in-file": "6.3.5", 81 | "rxjs": "7.8.2", 82 | "semver": "7.5.4", 83 | "shasum": "1.0.2", 84 | "stat-mode": "1.0.0", 85 | "strip-ansi": "6.0.1", 86 | "symlink-or-copy": "1.3.1", 87 | "systeminformation": "5.25.11", 88 | "tail": "2.2.6", 89 | "tough-cookie": "4.1.4", 90 | "validator": "7.2.0", 91 | "yargs": "17.7.2", 92 | "yarn": "1.22.19" 93 | }, 94 | "devDependencies": { 95 | "chai": "4.5.0", 96 | "chai-as-promised": "7.1.2", 97 | "eslint": "7.30.0", 98 | "eslint-plugin-ghost": "2.5.0", 99 | "has-ansi": "4.0.1", 100 | "mocha": "11.1.0", 101 | "nock": "13.2.9", 102 | "nyc": "17.1.0", 103 | "proxyquire": "2.1.3", 104 | "sinon": "20.0.0", 105 | "standard-version": "4.3.0", 106 | "tmp": "0.2.1" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@tryghost:quietJS", 4 | ":semanticCommitTypeAll(chore)" 5 | ], 6 | "ignoreDeps": [ 7 | "cli-table3", 8 | "standard-version", 9 | "validator" 10 | ], 11 | "packageRules": [ 12 | { 13 | "depTypeList": ["devDependencies"], 14 | "automerge": true, 15 | "automergeType": "branch" 16 | }, 17 | { 18 | "groupName": "CLI Tools", 19 | "packageNames": ["chalk", "inquirer", "listr", "log-symbols", "ora"], 20 | "updateTypes": ["minor", "patch"], 21 | "automerge": true 22 | }, 23 | { 24 | "groupName": "Well Known", 25 | "packageNames": ["debug", "fs-extra", "got", "lodash", "moment", "semver", "yarn"], 26 | "updateTypes": ["minor", "patch"], 27 | "automerge": true 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "prefer-arrow-callback": "off", 7 | "func-names": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/TestExtension/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Extension = require('../../../lib/extension'); 4 | 5 | class TestExtension extends Extension { 6 | 7 | } 8 | 9 | module.exports = TestExtension; 10 | -------------------------------------------------------------------------------- /test/fixtures/classes/test-invalid-command.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class TestInvalidCommand { 4 | 5 | } 6 | 7 | module.exports = TestInvalidCommand; 8 | -------------------------------------------------------------------------------- /test/fixtures/classes/test-invalid-process.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = class TestProcess {}; 4 | -------------------------------------------------------------------------------- /test/fixtures/classes/test-process-missing-methods.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const cli = require('../../../lib/index'); 4 | 5 | module.exports = class TestProcess extends cli.ProcessManager {}; 6 | -------------------------------------------------------------------------------- /test/fixtures/classes/test-process-wont-run.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const cli = require('../../../lib/index'); 4 | 5 | module.exports = class TestProcess extends cli.ProcessManager { 6 | static willRun() { 7 | return false; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /test/fixtures/classes/test-valid-command.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Command = require('../../../lib/index').Command; 3 | 4 | class TestValidCommand extends Command { 5 | static configure() { 6 | } 7 | } 8 | 9 | module.exports = TestValidCommand; 10 | -------------------------------------------------------------------------------- /test/fixtures/classes/test-valid-process.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const cli = require('../../../lib/index'); 4 | 5 | module.exports = class TestProcess extends cli.ProcessManager { 6 | static willRun() { 7 | return true; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /test/fixtures/ghost-2.0-rc.2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/ghost-2.0-rc.2.zip -------------------------------------------------------------------------------- /test/fixtures/ghost-2.0.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/ghost-2.0.1.zip -------------------------------------------------------------------------------- /test/fixtures/ghost-2.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/ghost-2.0.zip -------------------------------------------------------------------------------- /test/fixtures/ghost-invalid-cli.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/ghost-invalid-cli.zip -------------------------------------------------------------------------------- /test/fixtures/ghost-invalid-node.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/ghost-invalid-node.zip -------------------------------------------------------------------------------- /test/fixtures/ghostlts.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/ghostlts.zip -------------------------------------------------------------------------------- /test/fixtures/ghostold.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/ghostold.zip -------------------------------------------------------------------------------- /test/fixtures/ghostrelease.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/ghostrelease.zip -------------------------------------------------------------------------------- /test/fixtures/nopkg.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/nopkg.zip -------------------------------------------------------------------------------- /test/fixtures/notghost.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TryGhost/Ghost-CLI/da10d4acd82651dff7943ff3a47223cd3128da8f/test/fixtures/notghost.zip -------------------------------------------------------------------------------- /test/unit/commands/buster-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const proxyquire = require('proxyquire'); 5 | 6 | const modulePath = '../../../lib/commands/buster'; 7 | 8 | describe('Unit: Commands > Buster', function () { 9 | it('runs yarn cache clean', function () { 10 | const yarnStub = sinon.stub().resolves(); 11 | const runStub = sinon.stub().callsFake(function (yarn, text) { 12 | expect(yarn).to.be.an.instanceof(Promise); 13 | expect(text).to.equal('Clearing yarn cache'); 14 | return yarn; 15 | }); 16 | const BusterCommand = proxyquire(modulePath, { 17 | '../utils/yarn': yarnStub 18 | }); 19 | const instance = new BusterCommand({run: runStub}, {}); 20 | 21 | return instance.run().then(() => { 22 | expect(runStub.calledOnce).to.be.true; 23 | expect(yarnStub.calledOnce).to.be.true; 24 | expect(yarnStub.calledWithExactly(['cache', 'clean'])).to.be.true; 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/unit/commands/check-update-spec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const proxyquire = require('proxyquire'); 3 | const {expect} = require('chai'); 4 | 5 | const modulePath = '../../../lib/commands/check-update'; 6 | 7 | describe('Unit: Commands > check-update', function () { 8 | it('doesn\'t output anything if instance doesn\'t exist', async function () { 9 | const CheckUpdateCommand = require(modulePath); 10 | const log = sinon.stub(); 11 | const getInstance = sinon.stub().returns({version: null}); 12 | 13 | const cmd = new CheckUpdateCommand({log}, {getInstance}); 14 | 15 | await cmd.run(); 16 | 17 | expect(getInstance.calledOnce).to.be.true; 18 | expect(log.called).to.be.false; 19 | }); 20 | 21 | it('outputs clear message if no new versions are available', async function () { 22 | const loadVersions = sinon.stub().resolves({latest: '2.0.0', latestMajor: {v1: '1.0.0', v2: '2.0.0'}}); 23 | const CheckUpdateCommand = proxyquire(modulePath, { 24 | '../utils/version': {loadVersions} 25 | }); 26 | 27 | const log = sinon.stub(); 28 | const getInstance = sinon.stub().returns({version: '2.0.0'}); 29 | 30 | const cmd = new CheckUpdateCommand({log}, {getInstance}); 31 | await cmd.run(); 32 | 33 | expect(getInstance.calledOnce).to.be.true; 34 | expect(loadVersions.calledOnce).to.be.true; 35 | expect(log.calledThrice).to.be.true; 36 | }); 37 | 38 | it('logs out available new version', async function () { 39 | const loadVersions = sinon.stub().resolves({latest: '2.1.0', latestMajor: {v1: '1.0.0', v2: '2.1.0'}}); 40 | const CheckUpdateCommand = proxyquire(modulePath, { 41 | '../utils/version': {loadVersions} 42 | }); 43 | 44 | const log = sinon.stub(); 45 | const getInstance = sinon.stub().returns({version: '2.0.0'}); 46 | 47 | const cmd = new CheckUpdateCommand({log}, {getInstance}); 48 | await cmd.run(); 49 | 50 | expect(getInstance.calledOnce).to.be.true; 51 | expect(loadVersions.calledOnce).to.be.true; 52 | expect(log.calledThrice).to.be.true; 53 | expect(log.thirdCall.firstArg).to.match(/^Minor/); 54 | }); 55 | 56 | it('logs out available new minor and major version if available', async function () { 57 | const loadVersions = sinon.stub().resolves({latest: '4.1.0', latestMajor: {v1: '1.0.0', v2: '2.0.0', v3: '3.42.0'}}); 58 | const CheckUpdateCommand = proxyquire(modulePath, { 59 | '../utils/version': {loadVersions} 60 | }); 61 | 62 | const log = sinon.stub(); 63 | const getInstance = sinon.stub().returns({version: '3.1.0'}); 64 | 65 | const cmd = new CheckUpdateCommand({log}, {getInstance}); 66 | await cmd.run(); 67 | 68 | expect(getInstance.calledOnce).to.be.true; 69 | expect(loadVersions.calledOnce).to.be.true; 70 | expect(log.callCount).to.eql(4); 71 | expect(log.getCall(3).firstArg).to.match(/^Major/); 72 | }); 73 | 74 | it('logs out available new major version when on latest minor', async function () { 75 | const loadVersions = sinon.stub().resolves({latest: '4.1.0', latestMajor: {v1: '1.0.0', v2: '2.0.0', v3: '3.42.0', v4: '4.1.0'}}); 76 | const CheckUpdateCommand = proxyquire(modulePath, { 77 | '../utils/version': {loadVersions} 78 | }); 79 | 80 | const log = sinon.stub(); 81 | const getInstance = sinon.stub().returns({version: '3.42.0'}); 82 | 83 | const cmd = new CheckUpdateCommand({log}, {getInstance}); 84 | await cmd.run(); 85 | 86 | expect(getInstance.calledOnce).to.be.true; 87 | expect(loadVersions.calledOnce).to.be.true; 88 | expect(log.calledThrice).to.be.true; 89 | expect(log.thirdCall.firstArg).to.match(/^Major/); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/unit/commands/doctor/checks/binary-deps-spec.js: -------------------------------------------------------------------------------- 1 | const {expect} = require('chai'); 2 | const sinon = require('sinon'); 3 | const semver = require('semver'); 4 | 5 | const {SystemError} = require('../../../../../lib/errors'); 6 | const check = require('../../../../../lib/commands/doctor/checks/binary-deps'); 7 | 8 | describe('Unit: Doctor Checks > Binary Deps', function () { 9 | afterEach(() => { 10 | sinon.restore(); 11 | }); 12 | 13 | it('exports proper task', function () { 14 | expect(check.title).to.equal('Checking binary dependencies'); 15 | expect(check.task).to.be.a('function'); 16 | expect(check.category).to.deep.equal(['start']); 17 | }); 18 | 19 | it('skips if instance not set', function () { 20 | const skip = sinon.stub(); 21 | 22 | check.task({}, {skip}); 23 | expect(skip.calledOnce).to.be.true; 24 | }); 25 | 26 | it('does nothing if node versions are the same', function () { 27 | const skip = sinon.stub(); 28 | const instance = {nodeVersion: process.versions.node}; 29 | 30 | check.task({instance}, {skip}); 31 | expect(skip.called).to.be.false; 32 | }); 33 | 34 | it('throws error if node versions are different', function () { 35 | const skip = sinon.stub(); 36 | const instance = {nodeVersion: semver.inc(process.versions.node, 'major')}; 37 | 38 | try { 39 | check.task({instance}, {skip}); 40 | } catch (error) { 41 | expect(error).to.be.an.instanceof(SystemError); 42 | expect(error.message).to.include('node version has changed'); 43 | expect(skip.called).to.be.false; 44 | return; 45 | } 46 | 47 | expect.fail('should have thrown an error'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/unit/commands/doctor/checks/check-memory-spec.js: -------------------------------------------------------------------------------- 1 | const expect = require('chai').expect; 2 | const sinon = require('sinon'); 3 | 4 | const sysinfo = require('systeminformation'); 5 | const errors = require('../../../../../lib/errors'); 6 | 7 | const modulePath = '../../../../../lib/commands/doctor/checks/check-memory'; 8 | 9 | describe('Unit: Doctor Checks > Memory', function () { 10 | afterEach(() => { 11 | sinon.restore(); 12 | }); 13 | 14 | it('exports proper task', function () { 15 | const checkMem = require(modulePath); 16 | 17 | expect(checkMem.title).to.equal('Checking memory availability'); 18 | expect(checkMem.task).to.be.a('function'); 19 | expect(checkMem.enabled).to.a('function'); 20 | expect(checkMem.category).to.deep.equal(['install', 'start', 'update']); 21 | }); 22 | 23 | it('enabled is determined by check-mem argument', function () { 24 | const memCheck = require(modulePath); 25 | const ctx = { 26 | argv: {'check-mem': false} 27 | }; 28 | 29 | expect(memCheck.enabled(ctx)).to.be.false; 30 | ctx.argv['check-mem'] = true; 31 | expect(memCheck.enabled(ctx)).to.be.true; 32 | }); 33 | 34 | it('uses systeminformation to determine memory availability', function () { 35 | const memStub = sinon.stub(sysinfo, 'mem').rejects(new Error('systeminformation')); 36 | const memCheck = require(modulePath); 37 | 38 | return memCheck.task().catch((error) => { 39 | expect(error).to.be.an('error'); 40 | expect(error.message).to.equal('systeminformation'); 41 | expect(memStub.calledOnce).to.be.true; 42 | }); 43 | }); 44 | 45 | it('fails if not enough memory is available', function () { 46 | const memStub = sinon.stub(sysinfo, 'mem').resolves({available: 10, swapfree: 0}); 47 | const memCheck = require(modulePath); 48 | 49 | return memCheck.task().catch((error) => { 50 | expect(error).to.be.an.instanceof(errors.SystemError); 51 | expect(error.message).to.match(/MB of memory available for smooth operation/); 52 | expect(memStub.calledOnce).to.be.true; 53 | }); 54 | }); 55 | 56 | it('passes if there is enough memory', function () { 57 | const memStub = sinon.stub(sysinfo, 'mem').resolves({available: 157286400, swapfree: 0}); 58 | const memCheck = require(modulePath); 59 | 60 | return memCheck.task().then(() => { 61 | expect(memStub.calledOnce).to.be.true; 62 | }); 63 | }); 64 | 65 | it('passes if there is enough memory (using swap space)', function () { 66 | const memStub = sinon.stub(sysinfo, 'mem').resolves({available: 10, swapfree: 157286400}); 67 | const memCheck = require(modulePath); 68 | 69 | return memCheck.task().then(() => { 70 | expect(memStub.calledOnce).to.be.true; 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/unit/commands/doctor/checks/check-permissions-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | 5 | const execa = require('execa'); 6 | const errors = require('../../../../../lib/errors'); 7 | 8 | const checkPermissions = require('../../../../../lib/commands/doctor/checks/check-permissions'); 9 | 10 | describe('Unit: Doctor Checks > Util > checkPermissions', function () { 11 | afterEach(function () { 12 | sinon.restore(); 13 | }); 14 | 15 | it('falls back to check owner permissions if not specified', function () { 16 | const execaStub = sinon.stub(execa, 'shell').resolves({stdout: ''}); 17 | 18 | return checkPermissions().then(() => { 19 | expect(execaStub.calledWithExactly('find ./content ! -group ghost ! -user ghost', {maxBuffer: Infinity})).to.be.true; 20 | }); 21 | }); 22 | 23 | it('rejects with error if no Ghost can\'t access files', function () { 24 | const execaStub = sinon.stub(execa, 'shell').rejects({stderr: 'Permission denied'}); 25 | 26 | return checkPermissions('folder').then(() => { 27 | expect(false, 'error should have been thrown').to.be.true; 28 | }).catch((error) => { 29 | expect(error).to.be.an.instanceof(errors.SystemError); 30 | expect(error.message).to.match(/Ghost can't access some files or directories to check for correct permissions./); 31 | expect(execaStub.calledWithExactly('find ./ -type d ! -perm 775 ! -perm 755', {maxBuffer: Infinity})).to.be.true; 32 | }); 33 | }); 34 | 35 | it('rejects with error if execa command fails', function () { 36 | const execaStub = sinon.stub(execa, 'shell').rejects(new Error('oops, cmd could not be executed')); 37 | 38 | return checkPermissions('files').then(() => { 39 | expect(false, 'error should have been thrown').to.be.true; 40 | }).catch((error) => { 41 | expect(error).to.be.an.instanceof(errors.ProcessError); 42 | expect(error.message).to.match(/oops, cmd could not be executed/); 43 | expect(execaStub.calledWithExactly('find ./ -type f ! -path "./versions/*" ! -perm 664 ! -perm 644', {maxBuffer: Infinity})).to.be.true; 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/unit/commands/doctor/checks/free-space-spec.js: -------------------------------------------------------------------------------- 1 | const {expect} = require('chai'); 2 | const sinon = require('sinon'); 3 | 4 | const sysinfo = require('systeminformation'); 5 | const {SystemError} = require('../../../../../lib/errors'); 6 | 7 | const check = require('../../../../../lib/commands/doctor/checks/free-space'); 8 | 9 | describe('Unit: Doctor Checks > Free Space', function () { 10 | afterEach(() => { 11 | sinon.restore(); 12 | }); 13 | 14 | it('exports proper task', function () { 15 | expect(check.title).to.equal('Checking free space'); 16 | expect(check.task).to.be.a('function'); 17 | expect(check.category).to.deep.equal(['install', 'update']); 18 | }); 19 | 20 | it('handles error from systeminformation', function () { 21 | const stub = sinon.stub(sysinfo, 'fsSize').rejects(new Error('test-error')); 22 | const cwdStub = sinon.stub(process, 'cwd').returns('/test/directory'); 23 | 24 | return check.task({}).catch((error) => { 25 | expect(error).to.be.an('error'); 26 | expect(error.message).to.equal('test-error'); 27 | expect(stub.calledOnce).to.be.true; 28 | expect(cwdStub.calledOnce).to.be.true; 29 | }); 30 | }); 31 | 32 | it('handles error from systeminformation', function () { 33 | const stub = sinon.stub(sysinfo, 'fsSize').rejects(new Error('test-error')); 34 | const cwdStub = sinon.stub(process, 'cwd').returns('/test/directory'); 35 | 36 | return check.task({}).catch((error) => { 37 | expect(error).to.be.an('error'); 38 | expect(error.message).to.equal('test-error'); 39 | expect(stub.calledOnce).to.be.true; 40 | expect(cwdStub.calledOnce).to.be.true; 41 | }); 42 | }); 43 | 44 | it('does nothing if no matching mount points found', async function () { 45 | const stub = sinon.stub(sysinfo, 'fsSize').resolves([{ 46 | mount: '/not/matching/dir', 47 | size: 0, 48 | used: 0 49 | }]); 50 | const cwdStub = sinon.stub(process, 'cwd').returns('/not/matching/dir'); 51 | 52 | await check.task({instance: {dir: '/test/dir'}}); 53 | expect(stub.calledOnce).to.be.true; 54 | expect(cwdStub.called).to.be.false; 55 | }); 56 | 57 | it('errors if not enough space available', function () { 58 | const stub = sinon.stub(sysinfo, 'fsSize').resolves([{ 59 | mount: '/', 60 | size: 1000000000000, 61 | used: 1000000000 62 | }, { 63 | mount: '/test/dir', 64 | size: 0, 65 | used: 0 66 | }]); 67 | const cwdStub = sinon.stub(process, 'cwd').returns('/test/dir'); 68 | 69 | return check.task({}).catch((error) => { 70 | expect(error).to.be.an.instanceOf(SystemError); 71 | expect(stub.calledOnce).to.be.true; 72 | expect(cwdStub.calledOnce).to.be.true; 73 | }); 74 | }); 75 | 76 | it('succeeds if enough space available', async function () { 77 | const stub = sinon.stub(sysinfo, 'fsSize').resolves([{ 78 | mount: '/', 79 | size: 1000000000000, 80 | used: 1000000000 81 | }, { 82 | mount: '/test/dir', 83 | size: 0, 84 | used: 0 85 | }]); 86 | const cwdStub = sinon.stub(process, 'cwd').returns('/test/dir'); 87 | 88 | await check.task({instance: {dir: '/'}}); 89 | expect(stub.calledOnce).to.be.true; 90 | expect(cwdStub.called).to.be.false; 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /test/unit/commands/doctor/checks/install-folder-permissions-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('node:fs/promises'); 3 | const expect = require('chai').expect; 4 | const sinon = require('sinon'); 5 | const proxyquire = require('proxyquire'); 6 | 7 | const errors = require('../../../../../lib/errors'); 8 | 9 | const modulePath = '../../../../../lib/commands/doctor/checks/install-folder-permissions'; 10 | 11 | describe('Unit: Doctor Checks > installFolderPermissions', function () { 12 | afterEach(() => { 13 | sinon.restore(); 14 | }); 15 | 16 | it('throws error if current directory is not writable', function () { 17 | const accessStub = sinon.stub(fs, 'access').rejects(); 18 | const installFolderPermissions = require(modulePath).task; 19 | 20 | return installFolderPermissions({}).then(() => { 21 | expect(false, 'error should have been thrown').to.be.true; 22 | }).catch((error) => { 23 | expect(error).to.be.an.instanceof(errors.SystemError); 24 | expect(error.message).to.match(/is not writable by your user/); 25 | expect(accessStub.calledOnce).to.be.true; 26 | expect(accessStub.calledWith(process.cwd())).to.be.true; 27 | }); 28 | }); 29 | 30 | it('skips checking parent folder permissions if ctx.local is set', function () { 31 | const accessStub = sinon.stub(fs, 'access').resolves(); 32 | const checkDirectoryStub = sinon.stub().resolves(); 33 | const installFolderPermissions = proxyquire(modulePath, { 34 | './check-directory': checkDirectoryStub 35 | }).task; 36 | 37 | return installFolderPermissions({local: true}).then(() => { 38 | expect(accessStub.calledOnce).to.be.true; 39 | expect(checkDirectoryStub.called).to.be.false; 40 | }); 41 | }); 42 | 43 | it('skips checking parent folder permissions if os is not linux', function () { 44 | const accessStub = sinon.stub(fs, 'access').resolves(); 45 | const checkDirectoryStub = sinon.stub().resolves(); 46 | const installFolderPermissions = proxyquire(modulePath, { 47 | './check-directory': checkDirectoryStub 48 | }).task; 49 | 50 | const ctx = {system: {platform: {linux: false}}}; 51 | 52 | return installFolderPermissions(ctx).then(() => { 53 | expect(accessStub.calledOnce).to.be.true; 54 | expect(checkDirectoryStub.called).to.be.false; 55 | }); 56 | }); 57 | 58 | it('skips checking parent folder permissions if --no-setup-linux-user is passed', function () { 59 | const accessStub = sinon.stub(fs, 'access').resolves(); 60 | const checkDirectoryStub = sinon.stub().resolves(); 61 | const installFolderPermissions = proxyquire(modulePath, { 62 | './check-directory': checkDirectoryStub 63 | }).task; 64 | 65 | const ctx = {argv: {'setup-linux-user': false}, system: {platform: {linux: false}}}; 66 | 67 | return installFolderPermissions(ctx).then(() => { 68 | expect(accessStub.calledOnce).to.be.true; 69 | expect(checkDirectoryStub.called).to.be.false; 70 | }); 71 | }); 72 | 73 | it('runs checkParentAndAbove if local not set and platform is linux', function () { 74 | const accessStub = sinon.stub(fs, 'access').resolves(); 75 | const checkDirectoryStub = sinon.stub().resolves(); 76 | const installFolderPermissions = proxyquire(modulePath, { 77 | './check-directory': checkDirectoryStub 78 | }).task; 79 | 80 | const ctx = {system: {platform: {linux: true}}, argv: {'setup-linux-user': true}}; 81 | 82 | return installFolderPermissions(ctx).then(() => { 83 | expect(accessStub.calledOnce).to.be.true; 84 | expect(checkDirectoryStub.calledOnce).to.be.true; 85 | expect(checkDirectoryStub.calledWith(process.cwd())).to.be.true; 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/unit/commands/doctor/checks/logged-in-ghost-user-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const fs = require('fs'); 5 | 6 | const errors = require('../../../../../lib/errors'); 7 | const ghostUser = require('../../../../../lib/utils/use-ghost-user'); 8 | 9 | const loggedInGhostUser = require('../../../../../lib/commands/doctor/checks/logged-in-ghost-user'); 10 | 11 | describe('Unit: Doctor Checks > loggedInGhostUser', function () { 12 | afterEach(() => { 13 | sinon.restore(); 14 | }); 15 | 16 | it('enabled works', function () { 17 | expect(loggedInGhostUser.enabled({ 18 | system: {platform: {linux: false}} 19 | }), 'false if platform is not linux').to.be.false; 20 | }); 21 | 22 | it('skip works', function () { 23 | expect(loggedInGhostUser.skip({ 24 | instance: {process: {name: 'local'}} 25 | }), 'true if local process manager').to.be.true; 26 | }); 27 | 28 | it('rejects if user is logged in as ghost and ghost owns content folder', function () { 29 | const uidStub = sinon.stub(process, 'getuid').returns(1002); 30 | const ghostUserStub = sinon.stub(ghostUser, 'getGhostUid').returns({uid: 1002, guid: 1002}); 31 | const fsStub = sinon.stub(fs, 'lstatSync').returns({uid: 1002, gid: 1002}); 32 | 33 | try { 34 | loggedInGhostUser.task(); 35 | expect(false, 'error should have been thrown').to.be.true; 36 | } catch (error) { 37 | expect(error).to.exist; 38 | expect(uidStub.calledOnce).to.be.true; 39 | expect(fsStub.calledOnce).to.be.true; 40 | expect(ghostUserStub.calledOnce).to.be.true; 41 | expect(error).to.be.an.instanceof(errors.SystemError); 42 | expect(error.message).to.match(/You can't run commands with the "ghost" user./); 43 | } 44 | }); 45 | 46 | it('resolves if user is logged in as ghost but ghost doesn\'t own the content folder', function () { 47 | const uidStub = sinon.stub(process, 'getuid').returns(1002); 48 | const ghostUserStub = sinon.stub(ghostUser, 'getGhostUid').returns({uid: 1002, guid: 1002}); 49 | const fsStub = sinon.stub(fs, 'lstatSync').returns({uid: 1001, gid: 1001}); 50 | 51 | loggedInGhostUser.task(); 52 | expect(uidStub.calledOnce).to.be.true; 53 | expect(fsStub.calledOnce).to.be.true; 54 | expect(ghostUserStub.calledOnce).to.be.true; 55 | }); 56 | 57 | it('resolves if ghost user doesn\'t exist', function () { 58 | const uidStub = sinon.stub(process, 'getuid').returns(1002); 59 | const ghostUserStub = sinon.stub(ghostUser, 'getGhostUid').returns(false); 60 | const fsStub = sinon.stub(fs, 'lstatSync').returns({uid: 1001, gid: 1001}); 61 | 62 | loggedInGhostUser.task(); 63 | expect(uidStub.calledOnce).to.be.true; 64 | expect(fsStub.calledOnce).to.be.true; 65 | expect(ghostUserStub.calledOnce).to.be.true; 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/unit/commands/doctor/checks/logged-in-user-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const errors = require('../../../../../lib/errors'); 5 | const ghostUser = require('../../../../../lib/utils/use-ghost-user'); 6 | 7 | const loggedInUser = require('../../../../../lib/commands/doctor/checks/logged-in-user'); 8 | 9 | describe('Unit: Doctor Checks > loggedInUser', function () { 10 | afterEach(() => { 11 | sinon.restore(); 12 | }); 13 | 14 | it('enabled works', function () { 15 | expect(loggedInUser.enabled({ 16 | local: true, 17 | system: {platform: {linux: true}}, 18 | argv: {} 19 | }), 'false if local is true').to.be.false; 20 | expect(loggedInUser.enabled({ 21 | local: false, 22 | instance: {process: {name: 'local'}}, 23 | system: {platform: {linux: false}} 24 | }), 'false if local is false and process name is local').to.be.false; 25 | expect(loggedInUser.enabled({ 26 | local: false, 27 | instance: {process: {name: 'systemd'}}, 28 | system: {platform: {linux: false}} 29 | }), 'false if local is false and process name is not local and platform is not linux').to.be.false; 30 | expect(loggedInUser.enabled({ 31 | local: false, 32 | instance: {process: {name: 'systemd'}}, 33 | system: {platform: {linux: true}} 34 | }), 'true if local is false and process name is not local and platform is linux').to.be.true; 35 | expect(loggedInUser.enabled({ 36 | local: false, 37 | instance: {process: {name: 'systemd'}}, 38 | system: {platform: {linux: true}}, 39 | argv: {process: 'local'} 40 | }), 'false if local is false and process name is not local and platform is linux, but argv local is given').to.be.false; 41 | }); 42 | 43 | it('rejects if user name is ghost', function () { 44 | const processStub = sinon.stub(process, 'getuid').returns(501); 45 | const ghostUserStub = sinon.stub(ghostUser, 'getGhostUid').returns({uid: 501, guid: 501}); 46 | 47 | try { 48 | loggedInUser.task(); 49 | } catch (error) { 50 | expect(error).to.exist; 51 | expect(error).to.be.an.instanceof(errors.SystemError); 52 | expect(processStub.calledOnce).to.be.true; 53 | expect(ghostUserStub.calledOnce).to.be.true; 54 | } 55 | }); 56 | 57 | it('passes if user name is not ghost', function () { 58 | const processStub = sinon.stub(process, 'getuid').returns(1000); 59 | const ghostUserStub = sinon.stub(ghostUser, 'getGhostUid').returns(false); 60 | 61 | try { 62 | loggedInUser.task(); 63 | expect(processStub.calledOnce).to.be.true; 64 | expect(ghostUserStub.calledOnce).to.be.true; 65 | } catch (error) { 66 | expect(error).to.not.exist; 67 | } 68 | }); 69 | 70 | it('passes if ghost user exists but not currently used', function () { 71 | const processStub = sinon.stub(process, 'getuid').returns(1000); 72 | const ghostUserStub = sinon.stub(ghostUser, 'getGhostUid').returns({uid: 501, guid: 501}); 73 | 74 | try { 75 | loggedInUser.task(); 76 | expect(processStub.calledOnce).to.be.true; 77 | expect(ghostUserStub.calledOnce).to.be.true; 78 | } catch (error) { 79 | expect(error).to.not.exist; 80 | } 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/unit/commands/export-spec.js: -------------------------------------------------------------------------------- 1 | const {expect} = require('chai'); 2 | const sinon = require('sinon'); 3 | const proxyquire = require('proxyquire').noCallThru(); 4 | 5 | const {SystemError} = require('../../../lib/errors'); 6 | 7 | const modulePath = '../../../lib/commands/export'; 8 | 9 | describe('Unit: Commands > export', function () { 10 | it('runs export task if instance is running', async function () { 11 | const exportTask = sinon.stub().resolves(); 12 | const instance = { 13 | isRunning: sinon.stub().resolves(true) 14 | }; 15 | const ui = { 16 | run: sinon.stub().callsFake(fn => fn()), 17 | log: sinon.stub() 18 | }; 19 | const getInstance = sinon.stub().returns(instance); 20 | 21 | const ExportCommand = proxyquire(modulePath, {'../tasks/import': {exportTask}}); 22 | const cmd = new ExportCommand(ui, {getInstance}); 23 | 24 | await cmd.run({file: 'test-output.json'}); 25 | expect(getInstance.calledOnce).to.be.true; 26 | expect(instance.isRunning.calledOnce).to.be.true; 27 | expect(ui.run.calledOnce).to.be.true; 28 | expect(exportTask.calledOnceWithExactly(ui, instance, 'test-output.json')).to.be.true; 29 | expect(ui.log.calledOnce).to.be.true; 30 | }); 31 | 32 | it('prompts to start if not running and throws if not confirmed', async function () { 33 | const exportTask = sinon.stub().resolves(); 34 | const instance = { 35 | isRunning: sinon.stub().resolves(false) 36 | }; 37 | const ui = { 38 | confirm: sinon.stub().resolves(false), 39 | run: sinon.stub().callsFake(fn => fn()), 40 | log: sinon.stub() 41 | }; 42 | const getInstance = sinon.stub().returns(instance); 43 | 44 | const ExportCommand = proxyquire(modulePath, {'../tasks/import': {exportTask}}); 45 | const cmd = new ExportCommand(ui, {getInstance}); 46 | 47 | try { 48 | await cmd.run({file: 'test-output.json'}); 49 | } catch (error) { 50 | expect(error).to.be.an.instanceof(SystemError); 51 | expect(error.message).to.include('not currently running'); 52 | expect(getInstance.calledOnce).to.be.true; 53 | expect(instance.isRunning.calledOnce).to.be.true; 54 | expect(ui.confirm.calledOnce).to.be.true; 55 | expect(ui.run.called).to.be.false; 56 | expect(exportTask.called).to.be.false; 57 | expect(ui.log.called).to.be.false; 58 | return; 59 | } 60 | 61 | expect.fail('run should have errored'); 62 | }); 63 | 64 | it('prompts to start if not running and starts if confirmed', async function () { 65 | const exportTask = sinon.stub().resolves(); 66 | const instance = { 67 | isRunning: sinon.stub().resolves(false), 68 | start: sinon.stub().resolves(), 69 | checkEnvironment: sinon.stub() 70 | }; 71 | const ui = { 72 | confirm: sinon.stub().resolves(true), 73 | run: sinon.stub().callsFake(fn => fn()), 74 | log: sinon.stub() 75 | }; 76 | const getInstance = sinon.stub().returns(instance); 77 | 78 | const ExportCommand = proxyquire(modulePath, {'../tasks/import': {exportTask}}); 79 | const cmd = new ExportCommand(ui, {getInstance}); 80 | 81 | await cmd.run({file: 'test-output.json'}); 82 | expect(getInstance.calledOnce).to.be.true; 83 | expect(instance.isRunning.calledOnce).to.be.true; 84 | expect(ui.confirm.calledOnce).to.be.true; 85 | expect(instance.checkEnvironment.calledOnce).to.be.true; 86 | expect(ui.run.calledTwice).to.be.true; 87 | expect(instance.start.calledOnce).to.be.true; 88 | expect(exportTask.calledOnceWithExactly(ui, instance, 'test-output.json')).to.be.true; 89 | expect(ui.log.calledOnce).to.be.true; 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/unit/commands/ls-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const stripAnsi = require('strip-ansi'); 5 | 6 | const LsCommand = require('../../../lib/commands/ls'); 7 | 8 | describe('Unit: Commands > ls', function () { 9 | it('outputs the correct data for instances that are running and not running', async function () { 10 | const summaryStub = sinon.stub(); 11 | summaryStub.onFirstCall().resolves({ 12 | name: 'testa', 13 | dir: '/var/www/testa', 14 | version: '1.5.0', 15 | running: false 16 | }); 17 | summaryStub.onSecondCall().resolves({ 18 | name: 'testb', 19 | dir: '/var/www/testb', 20 | version: '1.2.0', 21 | running: true, 22 | mode: 'production', 23 | url: 'https://testa.com', 24 | port: 2369, 25 | process: 'systemd' 26 | }); 27 | summaryStub.onThirdCall().resolves({ 28 | name: 'testc', 29 | dir: '/var/www/testc', 30 | version: '1.3.0', 31 | running: true, 32 | mode: 'development', 33 | url: 'http://localhost:2370', 34 | port: 2370, 35 | process: 'local' 36 | }); 37 | const getAllInstancesStub = sinon.stub().resolves([ 38 | {summary: summaryStub}, 39 | {summary: summaryStub}, 40 | {summary: summaryStub} 41 | ]); 42 | const tableStub = sinon.stub(); 43 | 44 | const instance = new LsCommand({table: tableStub}, {getAllInstances: getAllInstancesStub}); 45 | 46 | await instance.run(); 47 | expect(summaryStub.calledThrice).to.be.true; 48 | expect(tableStub.calledOnce).to.be.true; 49 | expect(tableStub.args[0][0]).to.deep 50 | .equal(['Name', 'Location', 'Version', 'Status', 'URL', 'Port', 'Process Manager']); 51 | const rows = tableStub.args[0][1]; 52 | expect(rows).to.be.an.instanceof(Array); 53 | expect(rows).to.have.length(3); 54 | 55 | const expected = [ 56 | ['testa', '/var/www/testa', '1.5.0', 'stopped', 'n/a', 'n/a', 'n/a'], 57 | ['testb', '/var/www/testb', '1.2.0', 'running (production)', 'https://testa.com', 2369, 'systemd'], 58 | ['testc', '/var/www/testc', '1.3.0', 'running (development)', 'http://localhost:2370', 2370, 'local'] 59 | ]; 60 | 61 | expected.forEach((row, i) => { 62 | row.forEach((prop, j) => { 63 | expect(stripAnsi(rows[i][j])).to.equal(prop); 64 | }); 65 | }); 66 | }); 67 | 68 | it('Doesn\'t create a table when no instances exist', async function () { 69 | const getAllInstancesStub = sinon.stub().resolves([]); 70 | const tableStub = sinon.stub(); 71 | const logStub = sinon.stub(); 72 | 73 | const instance = new LsCommand({log: logStub, table: tableStub}, {getAllInstances: getAllInstancesStub}); 74 | await instance.run(); 75 | expect(tableStub.called).to.be.false; 76 | expect(logStub.calledOnce).to.be.true; 77 | expect(logStub.args[0][0]).to.equal('No installed ghost instances found'); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/unit/commands/migrate-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const proxyquire = require('proxyquire'); 4 | const sinon = require('sinon'); 5 | 6 | const modulePath = '../../../lib/commands/migrate'; 7 | 8 | function build(migrations) { 9 | const runStub = sinon.stub().callsFake((fn, title) => { 10 | expect(title).to.equal('Checking for available migrations'); 11 | return Promise.resolve(fn()); 12 | }); 13 | 14 | const ui = { 15 | run: runStub, 16 | listr: sinon.stub().resolves(), 17 | log: sinon.stub() 18 | }; 19 | 20 | const instance = { 21 | cliVersion: '1.1.0' 22 | }; 23 | 24 | const system = { 25 | cliVersion: '1.2.0', 26 | hook: sinon.stub().resolves(), 27 | getInstance: sinon.stub().returns(instance) 28 | }; 29 | 30 | const parseStub = sinon.stub().returns(migrations); 31 | 32 | const Cmd = proxyquire(modulePath, {'../utils/needed-migrations': parseStub}); 33 | 34 | return { 35 | cmd: new Cmd(ui, system), 36 | instance, 37 | parse: parseStub 38 | }; 39 | } 40 | 41 | describe('Unit: Commands > Migrate', function () { 42 | it('runs needed migrations', function () { 43 | const migrations = [{ 44 | title: 'Something', 45 | task: () => {} 46 | }]; 47 | 48 | const {cmd, instance, parse} = build(migrations); 49 | 50 | return cmd.run({}).then(() => { 51 | expect(cmd.ui.run.calledOnce).to.be.true; 52 | expect(cmd.system.hook.calledOnce).to.be.true; 53 | expect(parse.calledOnce).to.be.true; 54 | expect(cmd.ui.listr.calledOnce).to.be.true; 55 | expect(cmd.ui.listr.calledWith(migrations)).to.be.true; 56 | expect(instance.cliVersion).to.equal('1.2.0'); 57 | }); 58 | }); 59 | 60 | it('skips if no migrations', function () { 61 | const {cmd, instance, parse} = build([]); 62 | 63 | return cmd.run({}).then(() => { 64 | expect(cmd.ui.run.calledOnce).to.be.true; 65 | expect(cmd.system.hook.calledOnce).to.be.true; 66 | expect(parse.calledOnce).to.be.true; 67 | expect(cmd.ui.listr.calledOnce).to.be.false; 68 | expect(cmd.ui.log.calledOnce).to.be.true; 69 | expect(instance.cliVersion).to.equal('1.2.0'); 70 | }); 71 | }); 72 | 73 | it('quiet supresses output', function () { 74 | const {cmd, instance, parse} = build([]); 75 | 76 | return cmd.run({quiet: true}).then(() => { 77 | expect(cmd.ui.run.calledOnce).to.be.true; 78 | expect(cmd.system.hook.calledOnce).to.be.true; 79 | expect(parse.calledOnce).to.be.true; 80 | expect(cmd.ui.listr.calledOnce).to.be.false; 81 | expect(cmd.ui.log.calledOnce).to.be.false; 82 | expect(instance.cliVersion).to.equal('1.2.0'); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /test/unit/commands/restart-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | 5 | const modulePath = '../../../lib/commands/restart'; 6 | const RestartCommand = require(modulePath); 7 | 8 | describe('Unit: Command > Restart', function () { 9 | it('warns of stopped instance and starts instead', async function () { 10 | const instance = { 11 | isRunning: sinon.stub().resolves(false), 12 | start: sinon.stub().resolves() 13 | }; 14 | const ui = { 15 | log: sinon.stub(), 16 | run: sinon.stub().callsFake(fn => fn()) 17 | }; 18 | const system = { 19 | getInstance: sinon.stub().returns(instance) 20 | }; 21 | 22 | const command = new RestartCommand(ui, system); 23 | await command.run(); 24 | 25 | expect(instance.isRunning.calledOnce).to.be.true; 26 | expect(ui.log.calledOnce).to.be.true; 27 | expect(ui.log.args[0][0]).to.match(/not running!/); 28 | expect(ui.run.calledOnce).to.be.true; 29 | expect(instance.start.calledOnce).to.be.true; 30 | }); 31 | 32 | it('calls process restart method if instance is running', async function () { 33 | const instance = { 34 | isRunning: sinon.stub().resolves(true), 35 | loadRunningEnvironment: sinon.stub(), 36 | restart: sinon.stub().resolves() 37 | }; 38 | const ui = { 39 | log: sinon.stub(), 40 | run: sinon.stub().callsFake(fn => fn()) 41 | }; 42 | const system = { 43 | getInstance: sinon.stub().returns(instance) 44 | }; 45 | 46 | const command = new RestartCommand(ui, system); 47 | await command.run(); 48 | 49 | expect(instance.isRunning.calledOnce).to.be.true; 50 | expect(ui.log.called).to.be.false; 51 | expect(instance.loadRunningEnvironment.calledOnce).to.be.true; 52 | expect(ui.run.calledOnce).to.be.true; 53 | expect(instance.restart.calledOnce).to.be.true; 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/unit/commands/version-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const proxyquire = require('proxyquire'); 5 | const stripAnsi = require('strip-ansi'); 6 | 7 | const modulePath = '../../../lib/commands/version'; 8 | 9 | describe('Unit: Commands > Version', function () { 10 | it('only outputs ghost-cli version if not run in a ghost folder', function () { 11 | const VersionCommand = require(modulePath); 12 | const logStub = sinon.stub(); 13 | const getInstanceStub = sinon.stub().returns({version: null}); 14 | const cliVersion = '1.0.0'; 15 | const instance = new VersionCommand({log: logStub}, {getInstance: getInstanceStub, cliVersion: cliVersion}); 16 | 17 | instance.run(); 18 | expect(logStub.calledOnce).to.be.true; 19 | expect(stripAnsi(logStub.args[0][0])).to.match(/Ghost-CLI version: 1\.0\.0/); 20 | expect(getInstanceStub.calledOnce).to.be.true; 21 | }); 22 | 23 | it('outputs both ghost-cli and ghost version if run in a ghost install folder', function () { 24 | const homedirStub = sinon.stub().returns('/var/www'); 25 | const logStub = sinon.stub(); 26 | const getInstanceStub = sinon.stub().returns({version: '1.5.0', dir: '/var/www/ghost'}); 27 | const cliVersion = '1.0.0'; 28 | const VersionCommand = proxyquire(modulePath, { 29 | os: {homedir: homedirStub} 30 | }); 31 | const instance = new VersionCommand({log: logStub}, {getInstance: getInstanceStub, cliVersion: cliVersion}); 32 | 33 | instance.run(); 34 | expect(logStub.calledTwice).to.be.true; 35 | expect(stripAnsi(logStub.args[0][0])).to.match(/Ghost-CLI version: 1\.0\.0/); 36 | expect(stripAnsi(logStub.args[1][0])).to.match(/Ghost version: 1\.5\.0 \(at ~\/ghost\)/); 37 | expect(getInstanceStub.calledOnce).to.be.true; 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/unit/index-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const path = require('path'); 3 | const expect = require('chai').expect; 4 | const proxyquire = require('proxyquire').noCallThru(); 5 | 6 | const errors = require('../../lib/errors'); 7 | 8 | const modulePath = '../../lib/index'; 9 | 10 | describe('Unit: Index', function () { 11 | let requireMain; 12 | 13 | beforeEach(function () { 14 | requireMain = require.main.filename; 15 | }); 16 | 17 | afterEach(function () { 18 | require.main.filename = requireMain; 19 | }); 20 | 21 | it('requires local files if require.main is not a ghost executable', function () { 22 | require.main.filename = '/usr/lib/node_modules/mocha/bin/mocha'; 23 | const rootPath = '/usr/lib/node_modules/mocha/lib/index.js'; 24 | const rootPathObj = {this: 'is', not: 'what', should: 'be', required: true}; 25 | 26 | const cli = proxyquire(modulePath, { 27 | [rootPath]: rootPathObj 28 | }); 29 | 30 | expect(cli).to.not.deep.equal(rootPathObj); 31 | expect(cli.errors).to.deep.equal(errors); 32 | }); 33 | 34 | it('requires local files if require.main is the main ghost-cli instance', function () { 35 | const currentRoot = path.join(__dirname, '../../'); 36 | require.main.filename = path.join(currentRoot, 'bin/ghost'); 37 | const rootPath = path.join(currentRoot, 'lib/index.js'); 38 | const rootPathObj = {this: 'is', not: 'what', should: 'be', required: true}; 39 | 40 | const cli = proxyquire(modulePath, { 41 | [rootPath]: rootPathObj 42 | }); 43 | 44 | expect(cli).to.not.deep.equal(rootPathObj); 45 | expect(cli.errors).to.deep.equal(errors); 46 | }); 47 | 48 | it('requires rootPath if require.main is a different ghost-cli instance', function () { 49 | require.main.filename = '/usr/lib/node_modules/ghost-cli/bin/ghost'; 50 | const rootPath = '/usr/lib/node_modules/ghost-cli/lib/index.js'; 51 | const rootPathObj = {this: 'is', what: 'should', be: 'required'}; 52 | 53 | const cli = proxyquire(modulePath, { 54 | [rootPath]: rootPathObj 55 | }); 56 | 57 | expect(cli).to.deep.equal(rootPathObj); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/unit/tasks/ensure-structure-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const {setupTestFolder, cleanupTestFolders} = require('../../utils/test-folder'); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | 8 | const ensureStructure = require('../../../lib/tasks/ensure-structure'); 9 | 10 | describe('Unit: Tasks > ensure-structure', function () { 11 | after(() => { 12 | cleanupTestFolders(); 13 | }); 14 | 15 | it('works', function () { 16 | const env = setupTestFolder(); 17 | const cwdStub = sinon.stub(process, 'cwd').returns(env.dir); 18 | 19 | ensureStructure(); 20 | expect(cwdStub.calledOnce).to.be.true; 21 | 22 | const expectedFiles = [ 23 | 'versions', 24 | 'content/apps', 25 | 'content/themes', 26 | 'content/data', 27 | 'content/images', 28 | 'content/logs', 29 | 'content/settings' 30 | ]; 31 | 32 | expectedFiles.forEach((file) => { 33 | expect(fs.existsSync(path.join(env.dir, file))).to.be.true; 34 | }); 35 | 36 | cwdStub.restore(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/unit/tasks/linux-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const sinon = require('sinon'); 4 | const proxyquire = require('proxyquire'); 5 | const Promise = require('bluebird'); 6 | 7 | const modulePath = '../../../lib/tasks/linux'; 8 | 9 | function fakeListr(tasks, ctx) { 10 | expect(ctx).to.be.false; 11 | return Promise.each(tasks, (task) => { 12 | if (task.skip && task.skip()) { 13 | return; 14 | } 15 | 16 | return task.task(); 17 | }); 18 | } 19 | 20 | describe('Unit: Tasks > linux', function () { 21 | it('skips creating user if user already exists', function () { 22 | const shellStub = sinon.stub(); 23 | const linux = proxyquire(modulePath, { 24 | execa: {shellSync: shellStub} 25 | }); 26 | const listrStub = sinon.stub().callsFake(fakeListr); 27 | 28 | const sudoStub = sinon.stub().resolves(); 29 | const ui = {sudo: sudoStub, listr: listrStub}; 30 | 31 | return linux({ui: ui, instance: {dir: '/var/www/ghost'}}).then(() => { 32 | expect(shellStub.calledOnce).to.be.true; 33 | expect(listrStub.calledOnce).to.be.true; 34 | expect(sudoStub.calledOnce).to.be.true; 35 | expect(sudoStub.calledWithExactly('chown -R ghost:ghost /var/www/ghost/content')).to.be.true; 36 | }); 37 | }); 38 | 39 | it('creates user if user doesn\'t exist', function () { 40 | const shellStub = sinon.stub().throws(new Error('No such user')); 41 | const linux = proxyquire(modulePath, { 42 | execa: {shellSync: shellStub} 43 | }); 44 | const listrStub = sinon.stub().callsFake(fakeListr); 45 | const sudoStub = sinon.stub().resolves(); 46 | const ui = {sudo: sudoStub, listr: listrStub}; 47 | 48 | return linux({ui: ui, instance: {dir: '/var/www/ghost'}}).then(() => { 49 | expect(shellStub.calledOnce).to.be.true; 50 | expect(listrStub.calledOnce).to.be.true; 51 | expect(sudoStub.calledTwice).to.be.true; 52 | expect(sudoStub.args[0][0]).to.equal('useradd --system --user-group ghost'); 53 | expect(sudoStub.args[1][0]).to.equal('chown -R ghost:ghost /var/www/ghost/content'); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/unit/tasks/release-notes-spec.js: -------------------------------------------------------------------------------- 1 | const {expect} = require('chai'); 2 | const sinon = require('sinon'); 3 | const got = require('got'); 4 | 5 | const runTask = require('../../../lib/tasks/release-notes'); 6 | 7 | const stubbedGithubResponseWithRelevantFields = () => [{ 8 | tag_name: 'v4.0.1', 9 | name: '4.0.1', 10 | body: '4.0.1 release notes' 11 | }, { 12 | tag_name: '3.42.2', 13 | name: '3.42.2', 14 | body: '3.42.2 release notes' 15 | }]; 16 | 17 | describe('Unit: Tasks > Release Notes', function () { 18 | afterEach(function () { 19 | sinon.restore(); 20 | }); 21 | 22 | it('Discovers releases for < 4.x', async function () { 23 | const stub = sinon.stub(got, 'get').resolves({body: stubbedGithubResponseWithRelevantFields()}); 24 | const task = {title: 'original'}; 25 | const ui = {log: sinon.stub()}; 26 | const context = {ui, version: '3.42.2'}; 27 | 28 | await runTask(context, task); 29 | 30 | expect(stub.calledOnce).to.be.true; 31 | expect(task.title).to.equal('Fetched release notes'); 32 | expect(ui.log.args[0]).to.deep.equal(['\n# 3.42.2\n\n3.42.2 release notes\n', 'green']); 33 | }); 34 | 35 | it('Discovers release for >= 4.x', async function () { 36 | const stub = sinon.stub(got, 'get').resolves({body: stubbedGithubResponseWithRelevantFields()}); 37 | const task = {title: 'original'}; 38 | const ui = {log: sinon.stub()}; 39 | const context = {ui, version: '4.0.1'}; 40 | 41 | await runTask(context, task); 42 | 43 | expect(stub.calledOnce).to.be.true; 44 | expect(task.title).to.equal('Fetched release notes'); 45 | expect(ui.log.args[0]).to.deep.equal(['\n# 4.0.1\n\n4.0.1 release notes\n', 'green']); 46 | }); 47 | 48 | it('Complains when there are no release notes', async function () { 49 | const stub = sinon.stub(got, 'get').resolves({body: stubbedGithubResponseWithRelevantFields()}); 50 | const task = {title: 'original'}; 51 | const ui = {log: sinon.stub()}; 52 | const context = {ui, version: '3.14.15'}; 53 | 54 | await runTask(context, task); 55 | 56 | expect(stub.calledOnce).to.be.true; 57 | expect(task.title).to.equal('Release notes were not found'); 58 | }); 59 | 60 | it('Handles network errors', async function () { 61 | const stub = sinon.stub(got, 'get').rejects(new Error('What is this "GitHub" you speak of?')); 62 | const task = {title: 'original'}; 63 | const ui = {log: sinon.stub()}; 64 | const context = {ui, version: '3.14.15'}; 65 | 66 | await runTask(context, task); 67 | 68 | expect(stub.calledOnce).to.be.true; 69 | expect(task.title).to.equal('Unable to fetch release notes'); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/unit/utils/check-valid-install-spec.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const expect = require('chai').expect; 4 | const sinon = require('sinon'); 5 | const checkValidInstall = require('../../../lib/utils/check-valid-install'); 6 | 7 | const fs = require('fs-extra'); 8 | 9 | describe('Unit: Utils > checkValidInstall', function () { 10 | afterEach(() => { 11 | sinon.restore(); 12 | }); 13 | 14 | it('fails when config.js present', function () { 15 | const existsStub = sinon.stub(fs, 'existsSync'); 16 | existsStub.withArgs(sinon.match(/config\.js/)).returns(true); 17 | 18 | const errorStub = sinon.stub(console, 'error'); 19 | 20 | expect(checkValidInstall('test')).to.equal(false); 21 | expect(existsStub.calledOnce).to.be.true; 22 | expect(errorStub.calledOnce).to.be.true; 23 | expect(existsStub.args[0][0]).to.match(/config\.js/); 24 | expect(errorStub.args[0][0]).to.match(/Ghost-CLI only works with Ghost versions >= 1\.0\.0/); 25 | }); 26 | 27 | it('fails within a Ghost git clone', function () { 28 | const existsStub = sinon.stub(fs, 'existsSync'); 29 | const readJsonStub = sinon.stub(fs, 'readJsonSync'); 30 | 31 | existsStub.withArgs(sinon.match(/config\.js/)).returns(false); 32 | existsStub.withArgs(sinon.match(/package\.json/)).returns(true); 33 | existsStub.withArgs(sinon.match(/Gruntfile\.js/)).returns(true); 34 | readJsonStub.returns({name: 'ghost'}); 35 | 36 | const errorStub = sinon.stub(console, 'error'); 37 | 38 | expect(checkValidInstall('test')).to.equal(false); 39 | expect(existsStub.calledThrice).to.be.true; 40 | expect(errorStub.calledOnce).to.be.true; 41 | expect(existsStub.args[1][0]).to.match(/package\.json/); 42 | expect(errorStub.args[0][0]).to.match(/Ghost-CLI commands do not work inside of a git clone/); 43 | }); 44 | 45 | it('neither passes nor fails when .ghost-cli file is missing', function () { 46 | const existsStub = sinon.stub(fs, 'existsSync'); 47 | 48 | existsStub.withArgs(sinon.match(/config\.js/)).returns(false); 49 | existsStub.withArgs(sinon.match(/package\.json/)).returns(false); 50 | existsStub.withArgs(sinon.match(/\.ghost-cli/)).returns(false); 51 | 52 | expect(checkValidInstall('test')).to.equal(null); 53 | expect(existsStub.calledThrice).to.be.true; 54 | expect(existsStub.args[2][0]).to.match(/\.ghost-cli/); 55 | }); 56 | 57 | it('passes in "valid" installation', function () { 58 | const existsStub = sinon.stub(fs, 'existsSync'); 59 | 60 | existsStub.withArgs(sinon.match(/config\.js/)).returns(false); 61 | existsStub.withArgs(sinon.match(/package\.json/)).returns(false); 62 | existsStub.withArgs(sinon.match(/\.ghost-cli/)).returns(true); 63 | 64 | const errorStub = sinon.stub(console, 'error'); 65 | 66 | expect(checkValidInstall('test')).to.equal(true); 67 | expect(existsStub.calledThrice).to.be.true; 68 | expect(errorStub.called).to.be.false; 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/unit/utils/dir-is-empty.js: -------------------------------------------------------------------------------- 1 | const proxyquire = require('proxyquire'); 2 | const {expect} = require('chai'); 3 | 4 | const proxy = files => proxyquire('../../../lib/utils/dir-is-empty', { 5 | fs: {readdirSync: () => files} 6 | }); 7 | 8 | describe('Unit: Utils > dirIsEmpty', function () { 9 | it('returns true if directory is empty', function () { 10 | const fn = proxy([]); 11 | expect(fn('dir')).to.be.true; 12 | }); 13 | 14 | it('returns true if directory contains ghost debug log files', function () { 15 | const fn = proxy(['ghost-cli-debug-1234.log']); 16 | expect(fn('dir')).to.be.true; 17 | }); 18 | 19 | it('returns true if directory contains dotfiles other than .ghost-cli', function () { 20 | const fn = proxy(['.npmrc', '.gitignore']); 21 | expect(fn('dir')).to.be.true; 22 | }); 23 | 24 | it('returns false if directory contains .ghost-cli file', function () { 25 | const fn = proxy(['.ghost-cli']); 26 | expect(fn('dir')).to.be.false; 27 | }); 28 | 29 | it('returns false if directory contains other files', function () { 30 | const fn = proxy(['file.txt', 'file2.txt']); 31 | expect(fn('dir')).to.be.false; 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/unit/utils/find-extensions-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const sinon = require('sinon'); 5 | const proxyquire = require('proxyquire').noCallThru(); 6 | 7 | const modulePath = '../../../lib/utils/find-extensions'; 8 | 9 | const localExtensions = [ 10 | 'mysql', 11 | 'nginx', 12 | 'systemd' 13 | ]; 14 | 15 | describe('Unit: Utils > find-extensions', function () { 16 | let findExtensions; let findStub; let existsStub; 17 | 18 | beforeEach(() => { 19 | findStub = sinon.stub().returns([ 20 | { 21 | pkg: {name: 'test'} 22 | }, { 23 | pkg: { 24 | 'ghost-cli': {name: 'rest'} 25 | } 26 | }, { 27 | pkg: {} 28 | } 29 | ]); 30 | 31 | existsStub = sinon.stub(); 32 | 33 | findExtensions = proxyquire(modulePath, { 34 | 'find-plugins': findStub, 35 | 'global-modules': '.', 36 | fs: {existsSync: existsStub} 37 | }); 38 | }); 39 | 40 | it('calls find-plugins with proper args', function () { 41 | existsStub.returns(true); 42 | findExtensions(); 43 | expect(findStub.calledOnce).to.be.true; 44 | const args = findStub.args[0][0]; 45 | 46 | const expected = { 47 | keyword: 'ghost-cli-extension', 48 | configName: 'ghost-cli', 49 | scanAllDirs: true, 50 | dir: '.', 51 | sort: true 52 | }; 53 | 54 | const extensions = args.include.map(ext => ext.split('extensions/')[1]); 55 | delete args.include; 56 | expect(extensions).to.deep.equal(localExtensions); 57 | expect(args).to.deep.equal(expected); 58 | }); 59 | 60 | it('uses process.cwd() when global modules dir doesn\'t exist', function () { 61 | existsStub.returns(false); 62 | findExtensions(); 63 | expect(findStub.calledOnce).to.be.true; 64 | const args = findStub.args[0][0]; 65 | 66 | const expected = { 67 | keyword: 'ghost-cli-extension', 68 | configName: 'ghost-cli', 69 | scanAllDirs: true, 70 | dir: process.cwd(), 71 | sort: true 72 | }; 73 | 74 | const extensions = args.include.map(ext => ext.split('extensions/')[1]); 75 | delete args.include; 76 | expect(extensions).to.deep.equal(localExtensions); 77 | expect(args).to.deep.equal(expected); 78 | }); 79 | 80 | it('generates proper extension names', function () { 81 | existsStub.returns(true); 82 | const names = findExtensions().map(ext => ext.name); 83 | const expectedNames = ['test', 'rest', undefined]; 84 | expect(names).to.deep.equal(expectedNames); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/unit/utils/get-instance-spec.js: -------------------------------------------------------------------------------- 1 | const {expect} = require('chai'); 2 | const sinon = require('sinon'); 3 | const proxyquire = require('proxyquire').noCallThru(); 4 | const {SystemError} = require('../../../lib/errors'); 5 | const modulePath = '../../../lib/utils/get-instance'; 6 | 7 | describe('Unit: Utils > getInstance', function () { 8 | let getInstance; let stubs; let system; 9 | beforeEach(function () { 10 | stubs = { 11 | findValidInstallation: sinon.stub().callsFake(a => a), 12 | chdir: sinon.stub(process, 'chdir'), 13 | getInstance: sinon.stub().returns('It\'s-a Me, Mario!') 14 | }; 15 | 16 | system = {getInstance: stubs.getInstance}; 17 | getInstance = proxyquire(modulePath, { 18 | './find-valid-install': stubs.findValidInstallation 19 | }); 20 | }); 21 | 22 | this.afterEach(function () { 23 | sinon.restore(); 24 | }); 25 | 26 | it('Doesn\'t change directory by default', function () { 27 | const result = getInstance({name: undefined, system, command: 'test', recurse: false}); 28 | 29 | expect(result).to.equal('It\'s-a Me, Mario!'); 30 | expect(stubs.getInstance.calledOnce).to.be.true; 31 | expect(stubs.chdir.called).to.be.false; 32 | expect(stubs.findValidInstallation.calledOnce).to.be.true; 33 | expect(stubs.findValidInstallation.calledWithExactly('test', false)).to.be.true; 34 | }); 35 | 36 | it('Fails if the instance cannot be found', function () { 37 | stubs.getInstance.returns(null); 38 | 39 | try { 40 | getInstance({name: 'ghosted', system, command: 'test', recurse: false}); 41 | expect(false, 'Promise should have rejected').to.be.true; 42 | } catch (error) { 43 | expect(error).to.be.instanceof(SystemError); 44 | expect(error.message).to.equal('Ghost instance \'ghosted\' does not exist'); 45 | } 46 | }); 47 | 48 | it('Chdirs into instance directory when it exists', function () { 49 | const dir = '/path/to/ghost'; 50 | stubs.getInstance.returns({dir}); 51 | 52 | const result = getInstance({name: 'i', system, command: 'test', recurse: false}); 53 | 54 | expect(result.dir).to.equal(dir); 55 | expect(stubs.chdir.calledOnce).to.to.true; 56 | expect(stubs.chdir.calledWithExactly(dir)).to.be.true; 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/unit/utils/needed-migrations-spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const expect = require('chai').expect; 3 | const proxyquire = require('proxyquire'); 4 | 5 | const modulePath = '../../../lib/utils/needed-migrations'; 6 | 7 | describe('Unit: Utils > needed-migrations', function () { 8 | it('concatenates core migrations with extension migrations and filters out unnecessary migrations', function () { 9 | const coreMigrations = [{ 10 | before: '1.2.0', 11 | title: 'Migration One', 12 | task: () => {} 13 | }, { 14 | title: 'Migration Two', 15 | task: () => {} 16 | }]; 17 | 18 | const extensionOne = [{ 19 | before: '1.1.0', 20 | title: 'Extension One', 21 | task: () => {} 22 | }]; 23 | 24 | const extensionTwo = [{ 25 | before: '1.2.0', 26 | title: 'Extension Two', 27 | task: () => {} 28 | }]; 29 | 30 | const parse = proxyquire(modulePath, { 31 | '../migrations': coreMigrations 32 | }); 33 | 34 | const result = parse( 35 | '1.1.0', 36 | '1.3.0', 37 | [extensionOne, extensionTwo] 38 | ); 39 | 40 | expect(result).to.deep.equal(coreMigrations.concat(extensionTwo)); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/utils/config-stub.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const sinon = require('sinon'); 3 | 4 | module.exports = function createConfigStub() { 5 | const getStub = sinon.stub(); 6 | const setStub = sinon.stub(); 7 | const hasStub = sinon.stub(); 8 | const saveStub = sinon.stub(); 9 | 10 | const config = {get: getStub, set: setStub, has: hasStub, save: saveStub}; 11 | setStub.returns(config); 12 | 13 | return config; 14 | }; 15 | -------------------------------------------------------------------------------- /test/utils/stream.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | 'use strict'; 3 | const stream = require('stream'); 4 | const isString = require('lodash/isString'); 5 | 6 | function noopRead(stream) { 7 | return function () { 8 | stream.push(null); 9 | }; 10 | } 11 | 12 | function noopWrite(chunk, enc, next) { 13 | next(); 14 | } 15 | 16 | function writeWrap(writeFunc) { 17 | return function (chunk, enc, next) { 18 | if (!isString(chunk)) { 19 | // chunk is a buffer, convert it to string 20 | writeFunc(chunk.toString()); 21 | } else { 22 | writeFunc(chunk); 23 | } 24 | 25 | return next(); 26 | }; 27 | } 28 | 29 | const streamUtils = { 30 | getReadableStream: function getReadableStream(_read) { 31 | const readStream = stream.Readable(); 32 | 33 | readStream._read = _read || noopRead(readStream); 34 | return readStream; 35 | }, 36 | 37 | getWritableStream: function getWritableStream(_write, wrap) { 38 | const writeStream = stream.Writable({decodeStrings: false}); 39 | 40 | writeStream._write = _write ? (wrap ? writeWrap(_write) : _write) : noopWrite; 41 | 42 | return writeStream; 43 | }, 44 | 45 | mockStandardStreams: function mockStandardStreams(streamCallbacks, errorCallback) { 46 | streamCallbacks = streamCallbacks || {}; 47 | 48 | const streams = { 49 | stdin: streamUtils.getReadableStream(streamCallbacks.stdin), 50 | stdout: streamUtils.getWritableStream(streamCallbacks.stdout), 51 | stderr: streamUtils.getWritableStream(streamCallbacks.stderr || streamCallbacks.stdout) 52 | }; 53 | 54 | streams.stdin.on('error', errorCallback); 55 | streams.stdout.on('error', errorCallback); 56 | streams.stderr.on('error', errorCallback); 57 | 58 | return streams; 59 | } 60 | }; 61 | 62 | module.exports = streamUtils; 63 | -------------------------------------------------------------------------------- /test/utils/test-folder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs-extra'); 3 | const tmp = require('tmp'); 4 | const path = require('path'); 5 | const isObject = require('lodash/isObject'); 6 | 7 | const currentTestFolders = {}; 8 | 9 | const builtin = { 10 | full: { 11 | dirs: ['versions/1.0.0', 'content'], 12 | links: [ 13 | ['versions/1.0.0', 'current'], 14 | ['content', 'current/content'] 15 | ], 16 | files: [ 17 | { 18 | path: 'versions/1.0.0/package.json', 19 | content: { 20 | name: 'cli-testing', 21 | version: '1.0.0' 22 | }, 23 | json: true 24 | }, 25 | { 26 | path: 'versions/1.0.0/index.js', 27 | content: '' 28 | }, 29 | { 30 | path: '.ghost-cli', 31 | content: { 32 | 'cli-version': '0.0.1' 33 | }, 34 | json: true 35 | } 36 | ] 37 | } 38 | }; 39 | 40 | function setupTestFolder(typeOrDefinition, dir) { 41 | typeOrDefinition = typeOrDefinition || {}; // default to empty object 42 | 43 | const setup = isObject(typeOrDefinition) ? typeOrDefinition : builtin[typeOrDefinition]; 44 | 45 | if (!setup) { 46 | return null; 47 | } 48 | 49 | dir = dir || tmp.dirSync({unsafeCleanup: true}).name; 50 | 51 | if (setup.dirs) { 52 | setup.dirs.forEach((dirToCreate) => { 53 | fs.ensureDirSync(path.join(dir, dirToCreate)); 54 | }); 55 | } 56 | 57 | if (setup.files) { 58 | setup.files.forEach((file) => { 59 | fs[(file.json ? 'writeJsonSync' : 'writeFileSync')](path.join(dir, file.path), file.content); 60 | }); 61 | } 62 | 63 | if (setup.links) { 64 | setup.links.forEach((link) => { 65 | fs.ensureSymlinkSync(path.join(dir, link[0]), path.join(dir, link[1])); 66 | }); 67 | } 68 | 69 | const testFolder = { 70 | dir: dir, 71 | cleanup: () => { 72 | fs.removeSync(dir); 73 | delete currentTestFolders[dir]; 74 | } 75 | }; 76 | 77 | currentTestFolders[dir] = testFolder; 78 | return testFolder; 79 | } 80 | 81 | function cleanupTestFolders() { 82 | Object.keys(currentTestFolders).forEach((key) => { 83 | currentTestFolders[key].cleanup(); 84 | }); 85 | } 86 | 87 | module.exports = {setupTestFolder, cleanupTestFolders}; 88 | --------------------------------------------------------------------------------