├── .gitignore
├── docs
├── assets
│ ├── js
│ │ ├── 11.28002d60.js
│ │ ├── 2.d8f505d4.js
│ │ ├── 9.dca480d7.js
│ │ ├── 10.94ab74aa.js
│ │ ├── 4.9602ac92.js
│ │ ├── 7.e7d4a4a6.js
│ │ ├── 8.c52cc8a3.js
│ │ └── 3.dd60ef97.js
│ └── img
│ │ └── search.83621669.svg
├── logging.md
├── installation.md
├── authentication.md
├── .vuepress
│ └── config.js
├── 404.html
├── environment.md
├── README.md
├── failure.md
├── components.md
├── installation.html
├── authentication.html
├── logging.html
├── commands.md
├── environment.html
└── failure.html
├── test
├── fixtures
│ ├── prepend.fab
│ ├── prompt.fab
│ ├── logs
│ │ ├── server1-log.log
│ │ ├── server2-log.log
│ │ ├── ssh-log.log
│ │ ├── local-log.log
│ │ └── global-log.log
│ ├── logging.fab
│ ├── handler.fab
│ ├── simple.fab
│ ├── keys
│ │ ├── ssh.public
│ │ └── ssh.private
│ ├── advanced.fab
│ └── fabula.js
├── unit
│ ├── __snapshots__
│ │ └── handling.test.js.snap
│ ├── handling.test.js
│ ├── logging.test.js
│ └── compiler.test.js
├── util.js
├── bin.js
└── server.js
├── deploy.sh
├── src
├── index.js
├── commands
│ ├── index.js
│ ├── get.js
│ ├── put.js
│ ├── exec.js
│ ├── fabula.js
│ ├── cd.js
│ ├── ensure.js
│ ├── write.js
│ └── handle.js
├── utils.js
├── prompt.js
├── local.js
├── ssh.js
├── cli.js
├── run.js
├── logging.js
├── command.js
└── compile.js
├── .babelrc
├── .eslintrc
├── fabula.js
├── bin
└── fabula.js
├── recipes
├── setup-local-ssh.fab
├── setup-github-deployment.fab
└── install-nginx-on-ubuntu.fab
├── .github
└── main.workflow
├── dist
├── fabula-get.js
├── fabula-put.js
├── fabula-fabula.js
├── fabula-chunk3.js
├── fabula-cd.js
├── fabula-ensure.js
├── fabula-write.js
├── fabula-handle.js
├── fabula-chunk.js
└── fabula.js
├── jest.config.js
├── README.md
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | package-lock.json
2 | node_modules
3 | .vuepress/dist
4 |
--------------------------------------------------------------------------------
/docs/assets/js/11.28002d60.js:
--------------------------------------------------------------------------------
1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[11],{167:function(n,w,o){}}]);
--------------------------------------------------------------------------------
/test/fixtures/prepend.fab:
--------------------------------------------------------------------------------
1 |
2 | touch /usr/bin/sudotest
3 | service restart nginx
4 | write /tmp/file:
5 | test with prepend
6 |
7 |
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | rm -rf docs/assets
2 | vuepress build docs
3 | mv docs/.vuepress/dist/* docs/
4 | rm -rf docs/.vuepress/dist/
5 | git add docs
6 | git commit -m "docs: build update"
7 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 |
2 | export { default as cli } from './cli'
3 | export { compile } from './compile'
4 | export { loadConfig } from './cli'
5 | export { parseArgv } from './command'
6 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["env"],
3 | "env": {
4 | "test": {
5 | "plugins": [
6 | "babel-plugin-dynamic-import-node"
7 | ]
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/test/fixtures/prompt.fab:
--------------------------------------------------------------------------------
1 |
2 | export default async ({ prompt }) => ({
3 | configOption: await prompt('Config option:')
4 | })
5 |
6 |
7 |
8 | local echo <%= configOption %>
9 |
10 |
--------------------------------------------------------------------------------
/test/fixtures/logs/server1-log.log:
--------------------------------------------------------------------------------
1 | [server1] "server1 success test"
2 | [server1] [OK] bin.js --code 0 --stdout "server1 success test"
3 | [server1] "server1 error test"
4 | [server1] [FAIL] bin.js --code 1 --stderr "server1 error test"
5 |
--------------------------------------------------------------------------------
/test/fixtures/logs/server2-log.log:
--------------------------------------------------------------------------------
1 | [server2] "server2 success test"
2 | [server2] [OK] bin.js --code 0 --stdout "server2 success test"
3 | [server2] "server2 error test"
4 | [server2] [FAIL] bin.js --code 1 --stderr "server2 error test"
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | root: true,
3 | parserOptions: {
4 | parser: 'babel-eslint',
5 | sourceType: 'module',
6 | ecmaFeatures: {
7 | legacyDecorators: true
8 | }
9 | },
10 | extends: [
11 | '@nuxtjs'
12 | ]
13 | }
--------------------------------------------------------------------------------
/docs/assets/img/search.83621669.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/fabula.js:
--------------------------------------------------------------------------------
1 |
2 | export default {
3 | foobar: 1,
4 | ssh: {
5 | stored: {
6 | host: 'stored',
7 | username: 'ubuntu',
8 | hostname: '3.80.152.37',
9 | privateKey: '/Users/jonas/Keys/galvez'
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/commands/index.js:
--------------------------------------------------------------------------------
1 |
2 | export default [
3 | () => import('./handle'),
4 | () => import('./fabula'),
5 | () => import('./ensure'),
6 | () => import('./get'),
7 | () => import('./put'),
8 | () => import('./write'),
9 | () => import('./cd')
10 | ]
11 |
--------------------------------------------------------------------------------
/test/fixtures/logging.fab:
--------------------------------------------------------------------------------
1 | local node ../bin.js --code 0 --stdout "<%= $server.$id %> success test"
2 | local node ../bin.js --code 1 --stderr "<%= $server.$id %> error test"
3 | bin.js --code 0 --stdout "<%= $server.$id %> success test"
4 | bin.js --code 1 --stderr "<%= $server.$id %> error test"
5 |
--------------------------------------------------------------------------------
/bin/fabula.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const consola = require('consola')
4 |
5 | try {
6 | require('esm')(module)('../dist/fabula')
7 | .cli().catch((err) => {
8 | consola.fatal(err)
9 | process.exit(1)
10 | })
11 | } catch (err) {
12 | consola.fatal(err)
13 | process.exit(1)
14 | }
15 |
--------------------------------------------------------------------------------
/test/unit/__snapshots__/handling.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`test handling single handler 1`] = `
4 | "[INFO] [local] [OK] write /tmp/fabula-handler-test:
5 | [INFO] [local] 1
6 | [INFO] [local] [OK] cat /tmp/fabula-handler-test
7 | [INFO] [local] [OK] rm /tmp/fabula-handler-test
8 | "
9 | `;
10 |
--------------------------------------------------------------------------------
/src/commands/get.js:
--------------------------------------------------------------------------------
1 | import { get } from '../ssh'
2 |
3 | export default {
4 | match(line) {
5 | return line.trim().match(/^get\s+(.+)\s+(.+)/)
6 | },
7 | line() {
8 | this.params.sourcePath = this.match[1]
9 | this.params.targetPath = this.match[2]
10 | },
11 | command(conn) {
12 | return get(conn, this.params.sourcePath, this.param.targetPath)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/commands/put.js:
--------------------------------------------------------------------------------
1 | import { put } from '../ssh'
2 |
3 | export default {
4 | match(line) {
5 | return line.trim().match(/^put\s+(.+)\s+(.+)/)
6 | },
7 | line() {
8 | this.params.sourcePath = this.match[1]
9 | this.params.targetPath = this.match[2]
10 | },
11 | command(conn) {
12 | return put(conn, this.params.sourcePath, this.param.targetPath)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 |
2 | // Ported from Eric S. Raymond's code at:
3 | // https://github.com/python/cpython/blob/master/Lib/shlex.py
4 | export function quote(s) {
5 | if (!s) {
6 | return "''"
7 | }
8 | s = s.replace(/\n/g, '\\n')
9 | // Use single quotes, and put single quotes into double quotes
10 | // the string $'b is then quoted as '$'"'"'b'
11 | s = s.replace(/'/g, "'\"'\"'")
12 | return `'${s}'`
13 | }
14 |
--------------------------------------------------------------------------------
/test/fixtures/handler.fab:
--------------------------------------------------------------------------------
1 |
2 | export default {
3 | fail: false,
4 | handle: (result) => {
5 | return {
6 | touchErrorCode: result.code
7 | }
8 | }
9 | }
10 |
11 |
12 |
13 | touch /parent/doesnt/exist @handle:
14 | local write /tmp/fabula-handler-test:
15 | <%= touchErrorCode %>
16 | cat /tmp/fabula-handler-test
17 | rm /tmp/fabula-handler-test
18 |
19 |
--------------------------------------------------------------------------------
/test/fixtures/logs/ssh-log.log:
--------------------------------------------------------------------------------
1 | [server1] "server1 success test"
2 | [server1] [OK] bin.js --code 0 --stdout "server1 success test"
3 | [server2] "server2 success test"
4 | [server2] [OK] bin.js --code 0 --stdout "server2 success test"
5 | [server1] "server1 error test"
6 | [server1] [FAIL] bin.js --code 1 --stderr "server1 error test"
7 | [server2] "server2 error test"
8 | [server2] [FAIL] bin.js --code 1 --stderr "server2 error test"
9 |
--------------------------------------------------------------------------------
/test/fixtures/simple.fab:
--------------------------------------------------------------------------------
1 |
2 | module.exports = {
3 | env: {
4 | COMPONENT_VAR: 'COMPONENT_VAR'
5 | },
6 | branch: 'my-branch',
7 | someFlag: true
8 | }
9 |
10 |
11 |
12 | cd ~
13 | local mkdir -p foobar
14 | local git checkout <%= branch %>
15 | git checkout <%= branch %>-2
16 | local echo "foobarfobar" > foobar
17 |
18 | <% if (someFlag) { %>
19 | local touch /tmp/some-file
20 | <% } %>
21 |
22 |
--------------------------------------------------------------------------------
/src/prompt.js:
--------------------------------------------------------------------------------
1 | import prompts from 'prompts'
2 |
3 | async function simplePrompt(message) {
4 | const result = await prompts({
5 | name: 'value',
6 | type: 'text',
7 | message
8 | })
9 | return result.value
10 | }
11 |
12 | export default function prompt(params) {
13 | if (typeof params === 'string') {
14 | return simplePrompt(params)
15 | } else if (typeof params === 'object') {
16 | return prompts(params)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/docs/assets/js/2.d8f505d4.js:
--------------------------------------------------------------------------------
1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[2],{165:function(t,n,e){},166:function(t,n,e){"use strict";var a=e(165);e.n(a).a},168:function(t,n,e){"use strict";e.r(n);var a={functional:!0,props:{type:{type:String,default:"tip"},text:String,vertical:{type:String,default:"top"}},render:function(t,n){var e=n.props,a=n.slots;return t("span",{class:["badge",e.type,e.vertical]},e.text||a().default)}},r=(e(166),e(0)),i=Object(r.a)(a,void 0,void 0,!1,null,"099ab69c",null);n.default=i.exports}}]);
--------------------------------------------------------------------------------
/recipes/setup-local-ssh.fab:
--------------------------------------------------------------------------------
1 |
2 | export default {
3 | host: 'stored',
4 | hostname: '3.80.152.37',
5 | user: 'ubuntu',
6 | privateKey: '/Users/jonas/Keys/galvez'
7 | }
8 |
9 |
10 |
11 | Host <%= host %>
12 | Hostname <%= hostname %>
13 | User <%= user %>
14 | IdentityFile <%= privateKey %>
15 | StrictHostKeyChecking no
16 |
17 |
18 |
19 | local echo >> ~/.ssh/config # new line
20 | local append ~/.ssh/config strings.sshConfig
21 |
22 |
23 |
--------------------------------------------------------------------------------
/test/util.js:
--------------------------------------------------------------------------------
1 | import { parse } from 'path'
2 | import { readFileSync, writeFileSync } from 'fs'
3 | import { compile } from '../dist/fabula'
4 | export { loadConfig } from '../dist/fabula'
5 | export { parseArgv } from '../dist/fabula'
6 |
7 | export async function compileForTest(source, config) {
8 | const name = parse(source).name
9 | if (!source.endsWith('.fab')) {
10 | source += '.fab'
11 | }
12 | source = readFileSync(source).toString()
13 | const [ commands, settings ] = await compile(name, source, config)
14 | return commands(settings)
15 | }
16 |
--------------------------------------------------------------------------------
/.github/main.workflow:
--------------------------------------------------------------------------------
1 | workflow "CI" {
2 | on = "push"
3 | resolves = ["Install", "Audit", "Lint", "Test"]
4 | }
5 |
6 | action "Install" {
7 | uses = "nuxt/actions-yarn@master"
8 | args = "install --frozen-lockfile --non-interactive"
9 | }
10 |
11 | action "Audit" {
12 | uses = "nuxt/actions-yarn@master"
13 | args = "audit"
14 | }
15 |
16 | action "Lint" {
17 | uses = "nuxt/actions-yarn@master"
18 | needs = ["Install"]
19 | args = "lint"
20 | }
21 |
22 | action "Test" {
23 | uses = "actions/npm@master"
24 | needs = ["Install"]
25 | args = "test"
26 | }
27 |
--------------------------------------------------------------------------------
/dist/fabula-get.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('fs');
4 | require('consola');
5 | require('util');
6 | require('read');
7 | require('ssh2');
8 | const __chunk_1 = require('./fabula-chunk.js');
9 |
10 | const get = {
11 | match(line) {
12 | return line.trim().match(/^get\s+(.+)\s+(.+)/)
13 | },
14 | line() {
15 | this.params.sourcePath = this.match[1];
16 | this.params.targetPath = this.match[2];
17 | },
18 | command(conn) {
19 | return __chunk_1.get(conn, this.params.sourcePath, this.param.targetPath)
20 | }
21 | };
22 |
23 | exports.default = get;
24 |
--------------------------------------------------------------------------------
/dist/fabula-put.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('fs');
4 | require('consola');
5 | require('util');
6 | require('read');
7 | require('ssh2');
8 | const __chunk_1 = require('./fabula-chunk.js');
9 |
10 | const put = {
11 | match(line) {
12 | return line.trim().match(/^put\s+(.+)\s+(.+)/)
13 | },
14 | line() {
15 | this.params.sourcePath = this.match[1];
16 | this.params.targetPath = this.match[2];
17 | },
18 | command(conn) {
19 | return __chunk_1.put(conn, this.params.sourcePath, this.param.targetPath)
20 | }
21 | };
22 |
23 | exports.default = put;
24 |
--------------------------------------------------------------------------------
/test/unit/handling.test.js:
--------------------------------------------------------------------------------
1 | import { writeFileSync, readFileSync } from 'fs'
2 | import { spawnSync } from 'child_process'
3 | import { resolve } from 'path'
4 |
5 | const opts = { cwd: resolve(__dirname, '../fixtures') }
6 | const fabulaBin = resolve(__dirname, '../../bin/fabula.js')
7 | const spawnFabula = (...args) => spawnSync(fabulaBin, args, opts)
8 |
9 | describe('test handling', () => {
10 | test('single handler', async () => {
11 | const { stdout } = spawnFabula('handler')
12 | expect(stdout.toString().replace(/\[\d.+?\] /g, '')).toMatchSnapshot()
13 | })
14 | })
15 |
--------------------------------------------------------------------------------
/test/fixtures/logs/local-log.log:
--------------------------------------------------------------------------------
1 | [local] "server1 success test"
2 | [local] [OK] node ../bin.js --code 0 --stdout "server1 success test"
3 | [local] "server2 success test"
4 | [local] [OK] node ../bin.js --code 0 --stdout "server2 success test"
5 | [local] "server1 error test"
6 | [local] [OK] node ../bin.js --code 1 --stderr "server1 error test"
7 | [local] "server2 error test"
8 | [local] [OK] node ../bin.js --code 1 --stderr "server2 error test"
9 | [local] [OK] write /tmp/fabula-handler-test:
10 | [local] 1
11 | [local] [OK] cat /tmp/fabula-handler-test
12 | [local] [OK] rm /tmp/fabula-handler-test
13 |
--------------------------------------------------------------------------------
/src/commands/exec.js:
--------------------------------------------------------------------------------
1 | import { exec } from '../ssh'
2 | import { execLocal } from '../local'
3 |
4 | export default {
5 | prepend(prepend) {
6 | // Don't filter sudo from prepend for exec
7 | return prepend
8 | },
9 | match(line) {
10 | if (this.local) {
11 | this.params.cmd = line.split(/^local\s+/)[1]
12 | } else {
13 | this.params.cmd = line
14 | }
15 | return true
16 | },
17 | command(conn) {
18 | if (this.local) {
19 | return execLocal([this.argv[0], this.argv.slice(1)], this.env, this.settings.$cwd)
20 | } else {
21 | return exec(conn, this.params.cmd, this.env, this.settings.$cwd)
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/recipes/setup-github-deployment.fab:
--------------------------------------------------------------------------------
1 | # This script runs on the remote instance
2 | # to add Github SSH configuration for deployment
3 |
4 |
5 | export default {
6 | github: {
7 | deployKey: '/path/to/deploy_key'
8 | }
9 | }
10 |
11 |
12 |
13 | mkdir -p /app/.keys
14 | chown -R ubuntu /app/.keys
15 | chmod 755 /app/.keys
16 |
17 | put <%= github.deploy_key %> /app/.keys/deploy_key
18 | chmod 400 /app/.keys/deploy_key
19 |
20 | echo /home/<%= conn.settings.user %>/.ssh/config:
21 | Host <%= host %>
22 | Hostname <%= hostname %>
23 | IdentityFile /app/.keys/deploy_key
24 | User git
25 | StrictHostKeyChecking no
26 |
27 |
--------------------------------------------------------------------------------
/test/fixtures/keys/ssh.public:
--------------------------------------------------------------------------------
1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDOKLkF65teH5NBp6KVvwWHKxK5aJBLY4rWBWFEEmBv8stfR6ZU9ie1ygdOsn+8+IHKCUghLTtfdQEwWZJTOQmW4XBAGGC+c/xCDbeAhcdcomtYP1jPE4JD6wAKSTzqaL5nbLqtSknCKuzCIxL8twn9PlGAfx4RboC00MsvpArHo3r9fkzvTxbpafyMm4f5H9iVp5St/tfSnnj/Wa6v4NesmZE9kFED64UNmyAbTDd7KiJS/qHzkYESdLn5H7l4Eqjzm6BNbhBoPyyXev7BwxbkKL/d9t5mYK0vaSK676TyrKm3bnLOW71eUg3o326hqL0Gv7CumrP4/Hu55fzRNFYT7WPaVDrvtNqpB6Ss3CwVYnzUTzROvcRG3v96avAmw0uj9PB+7vZgAl/tcy/N34/ebuGPzPZvQ4pRqDi0BXQ/2z51Mr8lFnsgVb3rsPi/3vcaxlFxxERgmYlW+oUKaLgiVYvKfs409BNDZ92McqX+Ou6GNRBaT73YdRAMTGOfpQgV7C6DIdLVkpMB9qlbQWnRFRTgpQLYKPJJ5XvuOVwq6Mm7h/tFYPhxwXKSEUUiyHMQtticn54j/pyySuwQr5FbCIJJgVfjfntEzpNVvu0J5yAQWEwfb4CLHNaq9fP8VL9NI9NIUphpnx0FyLZuL0MqPlc10RQ8+XPnGqBQdnuHmw== fabula-test-ssh-server
2 |
--------------------------------------------------------------------------------
/docs/logging.md:
--------------------------------------------------------------------------------
1 | # Logging
2 |
3 | Logging can be configured in a fashion similar to environment variables: global,
4 | local, remote, per server and per component.
5 |
6 | [consola]: https://github.com/nuxt/consola
7 |
8 | ## Top level
9 |
10 | ```js
11 | export default {
12 | logs: {
13 | global: 'logs/global.log',
14 | local: 'logs/local.log',
15 | ssh: 'logs/ssh.log'
16 | },
17 | }
18 | ```
19 |
20 | ## Per server
21 |
22 | ```js
23 | export default {
24 | ssh: {
25 | server1: {
26 | hostname: '1.2.3.4',
27 | username: 'serveruser',
28 | log: 'logs/ssh-server1.log'
29 | }
30 | }
31 | }
32 | ```
33 |
34 | ## Per component
35 |
36 | ```js
37 |
38 | export default {
39 | log: 'logs/component.log'
40 | }
41 |
42 | ```
43 |
--------------------------------------------------------------------------------
/src/commands/fabula.js:
--------------------------------------------------------------------------------
1 | import { readFile } from 'fs'
2 | import { runSource, runLocalSource } from '../run'
3 |
4 | export default {
5 | match(line) {
6 | this.dedent = 0
7 | let match
8 | // eslint-disable-next-line no-cond-assign
9 | if (match = line.match(/^fabula\s+\.\s+([^ ]+?)\s*$/)) {
10 | this.params.filePath = this.match[1]
11 | return match
12 | }
13 | },
14 | async command(conn, logger) {
15 | const settings = {
16 | ...this.settings,
17 | fail: true
18 | }
19 | const commands = readFile(this.params.filePath).toString()
20 | if (this.local) {
21 | await runLocalSource(this.settings.$name, commands, settings, logger)
22 | } else {
23 | await runSource(this.context.server, conn, this.settings.$name, commands, settings, logger)
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/docs/installation.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | To install, use [`npm`][npm] or [`yarn`][yarn]:
4 |
5 | ```sh
6 | $ npm install fabula -g
7 | $ yarn global add fabula
8 | ```
9 |
10 | ## Highlighting
11 |
12 | As new and experimental as **Fabula** is, there's no code editor or IDE plugin available providing specialized syntax highlighting. For the moment, just set
13 | your editor's syntax settings to **XML** when editing **Fabula** files.
14 |
15 | ## Disclaimer
16 |
17 | **Fabula** is currently in **beta phase**, that is to say, we're still testing
18 | on several projects and making sure all potential issues are addressed. Don't
19 | use it in production just yet unless you're sure what you're doing. And please
20 | [file an issue on Github][gh-issue] if you find anything that needs attention.
21 |
22 | [npm]: https://www.npmjs.com/
23 | [yarn]: https://yarnpkg.com/
24 | [gh-issue]: https://github.com/nuxt/fabula/issues/new
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'node',
3 | expand: true,
4 | forceExit: true,
5 |
6 | // https://github.com/facebook/jest/pull/6747 fix warning here
7 | // But its performance overhead is pretty bad (30+%).
8 | // detectOpenHandles: true
9 |
10 | // setupTestFrameworkScriptFile: './test/utils/setup',
11 | // coverageDirectory: './coverage',
12 | // collectCoverageFrom: [],
13 | // coveragePathIgnorePatterns: [
14 | // 'node_modules/(?!(@nuxt|nuxt))',
15 | // 'packages/webpack/src/config/plugins/vue'
16 | // ],
17 | testPathIgnorePatterns: [
18 | 'bin/',
19 | 'node_modules/',
20 | 'test/fixtures/.*/.*?/'
21 | ],
22 |
23 | transform: {
24 | '^.+\\.js$': 'babel-jest'
25 | },
26 |
27 | moduleFileExtensions: [
28 | 'js',
29 | 'json'
30 | ],
31 |
32 | reporters: [
33 | 'default'
34 | ].concat(process.env.JEST_JUNIT_OUTPUT ? ['jest-junit'] : [])
35 | }
36 |
--------------------------------------------------------------------------------
/test/fixtures/logs/global-log.log:
--------------------------------------------------------------------------------
1 | [local] "server1 success test"
2 | [local] [OK] node ../bin.js --code 0 --stdout "server1 success test"
3 | [local] "server2 success test"
4 | [local] [OK] node ../bin.js --code 0 --stdout "server2 success test"
5 | [local] "server1 error test"
6 | [local] [OK] node ../bin.js --code 1 --stderr "server1 error test"
7 | [local] "server2 error test"
8 | [local] [OK] node ../bin.js --code 1 --stderr "server2 error test"
9 | [server1] "server1 success test"
10 | [server1] [OK] bin.js --code 0 --stdout "server1 success test"
11 | [server2] "server2 success test"
12 | [server2] [OK] bin.js --code 0 --stdout "server2 success test"
13 | [server1] "server1 error test"
14 | [server1] [FAIL] bin.js --code 1 --stderr "server1 error test"
15 | [server2] "server2 error test"
16 | [server2] [FAIL] bin.js --code 1 --stderr "server2 error test"
17 | [local] [OK] write /tmp/fabula-handler-test:
18 | [local] 1
19 | [local] [OK] cat /tmp/fabula-handler-test
20 | [local] [OK] rm /tmp/fabula-handler-test
21 |
--------------------------------------------------------------------------------
/test/bin.js:
--------------------------------------------------------------------------------
1 | async function main (shell = true, argv = process.argv) {
2 | const args = require('arg')({
3 | '--code': Number,
4 | '--stdout': String,
5 | '--stderr': String,
6 | }, { argv }),
7 | // eslint-disable-next-line no-eval
8 | stdout = args['--stdout'],
9 | // eslint-disable-next-line no-eval
10 | stderr = args['--stderr'],
11 | result = {
12 | code: args['--code'] || 0
13 | }
14 |
15 | if (stdout) {
16 | result.stdout = `${stdout}\n`
17 | }
18 | if (stderr) {
19 | result.stderr = `${stderr}\n`
20 | } else if (result.code) {
21 | result.stderr = `exit code: ${result.code}\n`
22 | }
23 | if (!shell) {
24 | return result
25 | }
26 |
27 | if (result.stdout) {
28 | process.stdout.write(result.stdout)
29 | }
30 | if (result.stderr || result.code) {
31 | process.stderr.write(result.stderr)
32 | }
33 | return result
34 | }
35 |
36 | module.exports = main
37 |
38 | if (require.main === module) {
39 | main().then() // (result) => process.exit(result.code))
40 | }
41 |
--------------------------------------------------------------------------------
/test/fixtures/advanced.fab:
--------------------------------------------------------------------------------
1 |
2 | module.exports = {
3 | k8s: {
4 | clusterName: 'my-cluster'
5 | },
6 | files: [
7 | { name: 'file1', contents: 'Contents \nof file1' },
8 | { name: 'file2', contents: 'Contents \' of file2' }
9 | ]
10 | }
11 |
12 |
13 |
14 | local cd ~
15 | local mkdir -p test
16 | write /tmp/test:
17 | goes into the file
18 |
19 | sudo service nginx restart
20 |
21 | # write will resolve its third argument
22 | # to the settings object (settings.files[index] etc)
23 | <% for (const file in files) { %>
24 | local write /tmp/<%= files[file].name %> files[<%= file %>].contents
25 | <% } %>
26 |
27 | # quote() prepares a string for the shell,
28 | # wrapping it in a single quote if needed and
29 | # escaping all unsafe characters
30 | <% for (const file of files) { %>
31 | local echo <%= quote(file.contents) %> > /tmp/<%= file.name %>
32 | <% } %>
33 |
34 | # Lines ending in \ continue into the next line (like Bash)
35 | gcloud container clusters create <%= k8s.clusterName %> \
36 | --machine-type=n1-standard-2 \
37 | --zone=southamerica-east1-a \
38 | --num-nodes=4
39 |
40 | ls test
41 |
42 |
--------------------------------------------------------------------------------
/test/fixtures/fabula.js:
--------------------------------------------------------------------------------
1 | const { launchTestSSHServer } = require('../server')
2 |
3 | function reporter (info) {
4 | return `${info.args.join(' ')}\n`
5 | }
6 |
7 | export default async function() {
8 | const [
9 | server1,
10 | server2
11 | ] = await Promise.all([
12 | launchTestSSHServer(),
13 | launchTestSSHServer()
14 | ])
15 | return {
16 | ssh: {
17 | server1: {
18 | log: { path: 'logs/server1-log.log', reporter },
19 | ...server1.settings
20 | },
21 | server2: {
22 | log: { path: 'logs/server2-log.log', reporter },
23 | ...server2.settings
24 | }
25 | },
26 | done() {
27 | server1.server.close()
28 | server2.server.close()
29 | },
30 | logs: {
31 | global: { path: 'logs/global-log.log', reporter },
32 | local: { path: 'logs/local-log.log', reporter },
33 | ssh: { path: 'logs/ssh-log.log', reporter }
34 | },
35 | env: {
36 | GLOBAL_VAR: 'GLOBAL_VAR',
37 | local: {
38 | GLOBAL_LOCAL_VAR: 'GLOBAL_LOCAL_VAR'
39 | },
40 | ssh: {
41 | GLOBAL_SSH_VAR: 'GLOBAL_SSH_VAR'
42 | }
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/local.js:
--------------------------------------------------------------------------------
1 | import { writeFileSync, appendFileSync } from 'fs'
2 | import { spawn } from 'child_process'
3 |
4 | export function execLocal(cmd, env = {}, cwd = null) {
5 | return new Promise((resolve) => {
6 | let stdout = ''
7 | let stderr = ''
8 | const options = {
9 | env: { ...process.env, ...env },
10 | cwd: cwd || process.cwd()
11 | }
12 |
13 | const stream = spawn(...cmd, options)
14 | stream.on('error', err => resolve(err))
15 | stream.stdout.on('data', (data) => { stdout += data })
16 | stream.stderr.on('data', (data) => { stderr += data })
17 | stream.on('exit', (code) => {
18 | resolve({ stdout, stderr, code })
19 | })
20 | })
21 | }
22 |
23 | export function localWrite(filePath, fileContents) {
24 | try {
25 | writeFileSync(filePath, fileContents)
26 | return { code: 0 }
27 | } catch (err) {
28 | return { code: 1, stderr: err.message || err.toString() }
29 | }
30 | }
31 |
32 | export function localAppend(filePath, fileContents) {
33 | try {
34 | appendFileSync(filePath, fileContents)
35 | return { code: 0 }
36 | } catch (err) {
37 | return { code: 1, stderr: err.message || err.toString() }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/dist/fabula-fabula.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | require('path');
5 | require('consola');
6 | require('lodash.merge');
7 | require('util');
8 | require('read');
9 | require('ssh2');
10 | require('./fabula-chunk.js');
11 | require('module');
12 | require('os');
13 | require('lodash.template');
14 | const __chunk_2 = require('./fabula-chunk2.js');
15 | require('child_process');
16 | require('./fabula-chunk3.js');
17 | require('prompts');
18 |
19 | const fabula = {
20 | match(line) {
21 | this.dedent = 0;
22 | let match;
23 | // eslint-disable-next-line no-cond-assign
24 | if (match = line.match(/^fabula\s+\.\s+([^ ]+?)\s*$/)) {
25 | this.params.filePath = this.match[1];
26 | return match
27 | }
28 | },
29 | async command(conn, logger) {
30 | const settings = {
31 | ...this.settings,
32 | fail: true
33 | };
34 | const commands = fs.readFile(this.params.filePath).toString();
35 | if (this.local) {
36 | await __chunk_2.runLocalSource(this.settings.$name, commands, settings, logger);
37 | } else {
38 | await __chunk_2.runSource(this.context.server, conn, this.settings.$name, commands, settings, logger);
39 | }
40 | }
41 | };
42 |
43 | exports.default = fabula;
44 |
--------------------------------------------------------------------------------
/docs/authentication.md:
--------------------------------------------------------------------------------
1 | # Authentication
2 |
3 | To configure access for servers, you must either ensure your local `ssh-agent`
4 | is running, or provide custom authentication settings for each server. If using
5 | an encrypted key, providing `privateKey` in addition to `hostname` and
6 | `username` usually suffices (`port` can also be set and defaults to `22`):
7 |
8 | ## Private key
9 |
10 | ```js
11 | export default {
12 | ssh: {
13 | server: {
14 | hostname: '1.2.3.4',
15 | privateKey: '/path/to/key'
16 | }
17 | }
18 | }
19 | ```
20 |
21 | Setting `privateKey` will skip `ssh-agent` and the authentication will be
22 | handled by **Fabula**. If you're using an encrypted key, you can provide the
23 | `passphrase` option, or you'll be automatically prompted for one when a task
24 | runs (recommended for safety).
25 |
26 | ## SSH agent
27 |
28 | If you fail to provide `privateKey`, **Fabula** will assume it should use the
29 | local `ssh-agent` and **will automatically use `process.env.SSH_AUTH_SOCK`**.
30 |
31 | You can override it by setting `agent` in `fabula.js`:
32 |
33 | ```js
34 | export default {
35 | agent: process.env.CUSTOM_SSH_AUTH_SOCK,
36 | ssh: {
37 | server: {
38 | hostname: '1.2.3.4',
39 | }
40 | }
41 | }
42 | ```
43 |
--------------------------------------------------------------------------------
/dist/fabula-chunk3.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const child_process = require('child_process');
5 |
6 | function execLocal(cmd, env = {}, cwd = null) {
7 | return new Promise((resolve) => {
8 | let stdout = '';
9 | let stderr = '';
10 | const options = {
11 | env: { ...process.env, ...env },
12 | cwd: cwd || process.cwd()
13 | };
14 |
15 | const stream = child_process.spawn(...cmd, options);
16 | stream.on('error', err => resolve(err));
17 | stream.stdout.on('data', (data) => { stdout += data; });
18 | stream.stderr.on('data', (data) => { stderr += data; });
19 | stream.on('exit', (code) => {
20 | resolve({ stdout, stderr, code });
21 | });
22 | })
23 | }
24 |
25 | function localWrite(filePath, fileContents) {
26 | try {
27 | fs.writeFileSync(filePath, fileContents);
28 | return { code: 0 }
29 | } catch (err) {
30 | return { code: 1, stderr: err.message || err.toString() }
31 | }
32 | }
33 |
34 | function localAppend(filePath, fileContents) {
35 | try {
36 | fs.appendFileSync(filePath, fileContents);
37 | return { code: 0 }
38 | } catch (err) {
39 | return { code: 1, stderr: err.message || err.toString() }
40 | }
41 | }
42 |
43 | exports.execLocal = execLocal;
44 | exports.localAppend = localAppend;
45 | exports.localWrite = localWrite;
46 |
--------------------------------------------------------------------------------
/docs/.vuepress/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | title: 'Fabula',
3 | description: 'Minimalist server configuration and task management.',
4 | base: '/fabula/',
5 | themeConfig: {
6 | repo: 'nuxt/fabula',
7 | editLinks: true,
8 | docsDir: 'docs',
9 | locales: {
10 | '/': {
11 | label: 'English',
12 | selectText: 'Languages',
13 | editLinkText: 'Edit this page on GitHub',
14 | sidebar: {
15 | '/': sidebarLinks('en')
16 | }
17 | },
18 | }
19 | }
20 | }
21 |
22 | function sidebarLinks (locale) {
23 | const translations = {
24 | en: {
25 | groups: {
26 | main: 'Guide'
27 | },
28 | 'page/': 'Introduction'
29 | }
30 | }
31 |
32 | const localePageTitle = (page) => {
33 | if (translations[locale][`page/${page}`]) {
34 | return [page, translations[locale][`page/${page}`]]
35 | }
36 | return page
37 | }
38 |
39 | const toc = [
40 | {
41 | title: translations[locale].groups.main,
42 | collapsable: false,
43 | children: [
44 | '',
45 | 'installation',
46 | 'environment',
47 | 'logging',
48 | 'components',
49 | 'commands',
50 | 'failure',
51 | 'authentication'
52 | ].map(child => localePageTitle(child))
53 | }
54 | ]
55 |
56 | return toc
57 | }
58 |
--------------------------------------------------------------------------------
/recipes/install-nginx-on-ubuntu.fab:
--------------------------------------------------------------------------------
1 |
2 | # From https://do.co/2OcRBq4
3 | # How To Install Nginx on Ubuntu 18.04
4 | # And register an example.com website afterwards
5 |
6 |
7 | export default {
8 | env: {
9 | DEBIAN_FRONTEND: 'noninteractive'
10 | },
11 | domain: 'example.com',
12 | dirs: {
13 | html: '/var/www/${domain}/html'
14 | conf: '/var/www/${domain}/conf'
15 | }
16 | }
17 |
18 |
19 |
20 | apt update -y
21 | apt install nginx -y
22 | mkdir -p <%= dirs.html %>
23 | chown -R <%= user %>:<%= user %> <%= dirs.html %>
24 | chmod -R 755 <%= dirs.html %>
25 | write <%= dirs.html %> strings['nginx.html']
26 | write <%= dirs.conf %> strings['nginx.conf']
27 | ln -s <%= dirs.conf %> /etc/nginx/sites-enabled/<%= domain %>
28 | systemctl reload nginx
29 |
30 |
31 |
32 |
33 |
34 | Welcome to Example.com!
35 |
36 |
37 | Success! The example.com server block is working!
38 |
39 |
40 |
41 |
42 |
43 | server {
44 | listen 80;
45 | listen [::]:80;
46 |
47 | root /var/www/<%= domain %>/html;
48 | index index.html;
49 | server_name <%= domain %> www.<%= domain %>;
50 | location / {
51 | try_files $uri $uri/ =404;
52 | }
53 | }
54 |
55 |
--------------------------------------------------------------------------------
/docs/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Fabula
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Fabula
3 |
4 | 
5 | Minimalist server configuration and task management.
6 |
7 |
8 | Go straight to the [full documentation][docs] if you'd like.
9 |
10 | Or read the [introductory blog post][post].
11 |
12 | [post]: https://hire.jonasgalvez.com.br/2019/may/05/a-vuejs-inspired-task-runner/
13 |
14 | # Introduction
15 |
16 | At its core, **Fabula** is a simple Bash script preprocessor and runner. It lets
17 | you run scripts **locally** and on **remote servers**. **Fabula** (latin for
18 | _story_) is inspired by Python's [Fabric][f].
19 |
20 | [f]: https://www.fabfile.org/
21 |
22 | ```xml
23 |
24 | export default {
25 | docsDir: {
26 | local: './docs',
27 | remote: '/remote/path/www'
28 | }
29 | }
30 |
31 |
32 |
33 | local vuepress build <%= docsDir.local %>
34 | put <%= docsDir.local %>/.vuepress/dist/ <%= docsDir.remote %>
35 | sudo service nginx restart
36 |
37 | ```
38 |
39 | Inspired by Vue, it lets you keep settings and commands in concise **single-file components**.
40 |
41 | Please refer to the [full documentation][docs] to learn more.
42 |
43 | [docs]: https://galvez.github.io/fabula/
44 |
45 | ## Meta
46 |
47 | Created by [Jonas Galvez][jg].
48 |
49 | [jg]: http://hire.jonasgalvez.com.br
50 |
--------------------------------------------------------------------------------
/docs/environment.md:
--------------------------------------------------------------------------------
1 | # Environment
2 |
3 | Environment variables can bet set in various ways.
4 |
5 | ## Global
6 |
7 | To set environment variables globally for both local and remote settings,
8 | assign keys to the `env` object in **Fabula**'s configuration file:
9 |
10 | ```js
11 | export default {
12 | env: {
13 | FOOBAR: 'foobar'
14 | }
15 | }
16 | ```
17 |
18 | Note, however, that the keys `local` and `ssh` are reserved.
19 |
20 | ## Local
21 |
22 | Use `env.local` in **Fabula**'s configuration file (`fabula.js`):
23 |
24 | ```js
25 | export default {
26 | env: {
27 | local: {
28 | FOOBAR: 'foobar'
29 | }
30 | }
31 | }
32 | ```
33 |
34 | ## Remote
35 |
36 | Use `env.ssh` in **Fabula**'s configuration file (`fabula.js`):
37 |
38 | ```js
39 | export default {
40 | env: {
41 | ssh: {
42 | FOOBAR: 'foobar'
43 | }
44 | }
45 | }
46 | ```
47 |
48 | This sets environment variables for all remote servers. These variables are used
49 | in **every remote command**, from any **Fabula** task file.
50 |
51 | ## Per server
52 |
53 | You may also place `env` underneath each SSH server:
54 |
55 | ```js
56 | export default {
57 | ssh: {
58 | server1: {
59 | hostname: '1.2.3.4',
60 | privateKey: '/path/to/key',
61 | env: {
62 | FOOBAR: 'foobar'
63 | }
64 | }
65 | }
66 | }
67 | ```
68 |
69 | Environment variables set for a remote server are used for every command that
70 | is ran on that server from any **Fabula** task file.
71 |
72 | ## Per component
73 |
74 | ```js
75 |
76 | export default {
77 | env: {
78 | FOOBAR: 'foobar'
79 | }
80 | }
81 |
82 | ```
83 |
84 | Environment variables set in a **Fabula** component are only used for the
85 | commands contained in it, both local and remote.
86 |
--------------------------------------------------------------------------------
/test/unit/logging.test.js:
--------------------------------------------------------------------------------
1 | import { writeFileSync, readFileSync } from 'fs'
2 | import { spawnSync } from 'child_process'
3 | import { resolve } from 'path'
4 |
5 | const logs = [
6 | 'global',
7 | 'local',
8 | 'ssh',
9 | 'server1',
10 | 'server2'
11 | ].reduce((obj, log) => {
12 | const logPath = `../fixtures/logs/${log}-log.log`.split('/')
13 | return { ...obj, [log]: resolve(__dirname, ...logPath) }
14 | }, {})
15 |
16 | const opts = { cwd: resolve(__dirname, '../fixtures') }
17 | const fabulaBin = resolve(__dirname, '../../bin/fabula.js')
18 | const spawnFabula = (...args) => spawnSync(fabulaBin, args, opts)
19 |
20 | function extractLogs(stdout, stderr) {
21 | stdout = stdout.split(/\n/)
22 | stderr = stderr.split(/\n/)
23 | return {
24 | global: stdout,
25 | local: stdout.filter(line => line.match(/\[local\]/)),
26 | ssh: stdout.filter(line => line.match(/\[server\d\]/)),
27 | server1: stdout.filter(line => line.match(/\[server1\]/)),
28 | server2: stdout.filter(line => line.match(/\[server2\]/)),
29 | }
30 | }
31 |
32 | describe('test logging', () => {
33 | test('all logs', async () => {
34 | await Promise.all(
35 | Object.keys(logs).map((log) => new Promise((resolve) => {
36 | writeFileSync(logs[log], '')
37 | resolve()
38 | }))
39 | )
40 | const { stdout, stderr } = spawnFabula('all', 'logging')
41 | const output = extractLogs(
42 | stdout.toString().trim(),
43 | stderr.toString().trim()
44 | )
45 | const compareLogs = (raw, msg) => {
46 | raw.split(/\n/).forEach((line, i) => {
47 | expect(msg[i]).toContain(line)
48 | })
49 | }
50 | for (const log in logs) {
51 | compareLogs(readFileSync(logs[log]).toString().trim(), output[log])
52 | }
53 | })
54 | })
55 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fabula",
3 | "version": "0.2.0",
4 | "description": "Minimalist server configuration and task management",
5 | "author": "Jonas Galvez ",
6 | "license": "MIT",
7 | "engines": {
8 | "node": ">= 10.0.0"
9 | },
10 | "bin": {
11 | "fabula": "./bin/fabula.js"
12 | },
13 | "main": "dist/fabula.js",
14 | "files": [
15 | "bin",
16 | "dist"
17 | ],
18 | "scripts": {
19 | "build": "rollup -c build/rollup.config.js",
20 | "test": "jest --detectOpenHandles --runInBand",
21 | "lint": "eslint --fix --ext .js,.vue src",
22 | "docs:dev": "vuepress dev docs",
23 | "docs:build": "source deploy.sh"
24 | },
25 | "dependencies": {
26 | "arg": "3.0.0",
27 | "consola": "2.3.0",
28 | "esm": "^3.2.25",
29 | "js-yaml": "^3.13.1",
30 | "lodash": "^4.17.15",
31 | "lodash.template": "^4.5.0",
32 | "mem": "^5.1.1",
33 | "prompts": "^2.0.4",
34 | "read": "1.0.7",
35 | "ssh2": "0.6.1"
36 | },
37 | "devDependencies": {
38 | "@nuxtjs/eslint-config": "0.0.1",
39 | "babel-eslint": "^10.0.1",
40 | "babel-jest": "^23.6.0",
41 | "babel-preset-env": "^1.7.0",
42 | "buffer-equal-constant-time": "^1.0.1",
43 | "eslint": "^5.9.0",
44 | "eslint-config-standard": "^12.0.0",
45 | "eslint-plugin-import": "^2.14.0",
46 | "eslint-plugin-jest": "^22.0.0",
47 | "eslint-plugin-node": "^8.0.0",
48 | "eslint-plugin-promise": "^4.0.1",
49 | "eslint-plugin-standard": "^4.0.0",
50 | "eslint-plugin-vue": "^4.7.1",
51 | "ip": "^1.1.5",
52 | "jest": "^23.6.0",
53 | "rollup": "^1.10.0",
54 | "rollup-plugin-alias": "^1.5.1",
55 | "rollup-plugin-babel": "^4.3.2",
56 | "rollup-plugin-commonjs": "^9.3.4",
57 | "rollup-plugin-json": "^4.0.0",
58 | "rollup-plugin-license": "^0.8.1",
59 | "rollup-plugin-node-resolve": "^4.2.3",
60 | "rollup-plugin-replace": "^2.2.0",
61 | "vuepress": "0.14.8"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/commands/cd.js:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import { runSource, runLocalSource } from '../run'
3 |
4 | export default {
5 | name: 'cd',
6 | patterns: {
7 | block: /^(?:local\s*)?cd\s+(.+?):\s*$/,
8 | global: /^(?:local\s*)?cd\s+([^ :]+?)\s*$/
9 | },
10 | match(line) {
11 | this.dedent = 0
12 | if (this.argv[0] === 'cd') {
13 | let match
14 | // eslint-disable-next-line no-cond-assign
15 | if (match = line.match(this.cmd.patterns.block)) {
16 | this.block = true
17 | return match
18 | // eslint-disable-next-line no-cond-assign
19 | } else if (match = line.match(this.cmd.patterns.global)) {
20 | this.global = true
21 | return match
22 | }
23 | }
24 | },
25 | line(line) {
26 | if (this.firstLine) {
27 | if (this.global) {
28 | this.settings.$cwd = this.match[1]
29 | return false
30 | } else {
31 | this.params.cwd = this.match[1]
32 | this.params.commands = []
33 | return true
34 | }
35 | } else if (!/^\s+/.test(line)) {
36 | return false
37 | } else {
38 | if (this.params.commands.length === 0) {
39 | const match = line.match(/^\s+/)
40 | if (match) {
41 | this.dedent = match[0].length
42 | }
43 | }
44 | this.params.commands.push(line.slice(this.dedent))
45 | return true
46 | }
47 | },
48 | async command(conn, logger) {
49 | const settings = {
50 | ...this.settings,
51 | $cwd: resolve(
52 | this.settings.$cwd || process.cwd(),
53 | this.params.cwd
54 | )
55 | }
56 | const commands = this.params.commands.map((cmd) => {
57 | if (this.local && !/^\s+/.test(cmd)) {
58 | cmd = `local ${cmd}`
59 | }
60 | return cmd
61 | }).join('\n')
62 | if (this.local) {
63 | await runLocalSource(this.settings.$name, commands, settings, logger)
64 | } else {
65 | await runSource(this.context.server, conn, this.settings.$name, commands, settings, logger)
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/commands/ensure.js:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import { runSource, runLocalSource } from '../run'
3 |
4 | export default {
5 | name: 'ensure',
6 | patterns: {
7 | block: /^(?:local\s*)?ensure\s+(.+?):\s*$/,
8 | global: /^(?:local\s*)?ensure\s+([^ :]+?)\s*$/
9 | },
10 | match(line) {
11 | this.dedent = 0
12 | if (this.argv[0] === 'ensure') {
13 | let match
14 | // eslint-disable-next-line no-cond-assign
15 | if (match = line.match(this.cmd.patterns.block)) {
16 | this.block = true
17 | return match
18 | // eslint-disable-next-line no-cond-assign
19 | } else if (match = line.match(this.cmd.patterns.global)) {
20 | this.global = true
21 | return match
22 | }
23 | }
24 | },
25 | line(line) {
26 | if (this.firstLine) {
27 | if (this.global) {
28 | this.settings.$cwd = this.match[1]
29 | return false
30 | } else {
31 | this.params.cwd = this.match[1]
32 | this.params.commands = []
33 | return true
34 | }
35 | } else if (!/^\s+/.test(line)) {
36 | return false
37 | } else {
38 | if (this.params.commands.length === 0) {
39 | const match = line.match(/^\s+/)
40 | if (match) {
41 | this.dedent = match[0].length
42 | }
43 | }
44 | this.params.commands.push(line.slice(this.dedent))
45 | return true
46 | }
47 | },
48 | async command(conn, logger) {
49 | const settings = {
50 | ...this.settings,
51 | fail: true,
52 | $cwd: resolve(
53 | this.settings.$cwd || process.cwd(),
54 | this.params.cwd
55 | )
56 | }
57 | const commands = this.params.commands.map((cmd) => {
58 | if (this.local && !/^\s+/.test(cmd)) {
59 | cmd = `local ${cmd}`
60 | }
61 | return cmd
62 | }).join('\n')
63 | if (this.local) {
64 | await runLocalSource(this.settings.$name, commands, settings, logger)
65 | } else {
66 | await runSource(this.context.server, conn, this.settings.$name, commands, settings, logger)
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/commands/write.js:
--------------------------------------------------------------------------------
1 | import { write, append } from '../ssh'
2 | import { localWrite, localAppend } from '../local'
3 |
4 | export default {
5 | patterns: {
6 | block: (argv) => {
7 | return new RegExp(`^(?:local\\s*)?${argv[0]}\\s+(.+?):$`)
8 | },
9 | string: (argv) => {
10 | return new RegExp(`^(?:local\\s*)?${argv[0]}\\s+([^ ]+?)\\s+([^ :]+?)$`)
11 | }
12 | },
13 | match(line) {
14 | this.op = this.argv[0]
15 | this.dedent = 0
16 | if (['append', 'write'].includes(this.argv[0])) {
17 | let match
18 | // eslint-disable-next-line no-cond-assign
19 | if (match = line.match(this.cmd.patterns.block(this.argv))) {
20 | this.block = true
21 | return match
22 | // eslint-disable-next-line no-cond-assign
23 | } else if (match = line.match(this.cmd.patterns.string(this.argv))) {
24 | this.string = true
25 | return match
26 | }
27 | }
28 | },
29 | line(line) {
30 | if (this.firstLine) {
31 | this.params.filePath = this.match[1]
32 | this.params.fileContents = []
33 | if (this.string) {
34 | const settingsKey = this.match[2]
35 | this.params.fileContents = () => {
36 | // eslint-disable-next-line no-eval
37 | return eval(`this.settings.${settingsKey}`).split(/\n/g)
38 | }
39 | return false
40 | } else {
41 | return true
42 | }
43 | } else if (!/^\s+/.test(line)) {
44 | return false
45 | } else {
46 | if (this.params.fileContents.length === 0) {
47 | const match = line.match(/^\s+/)
48 | if (match) {
49 | this.dedent = match[0].length
50 | }
51 | }
52 | this.params.fileContents.push(line.slice(this.dedent))
53 | return true
54 | }
55 | },
56 | command(conn) {
57 | const filePath = this.params.filePath
58 | const fileContents = typeof this.params.fileContents === 'function'
59 | ? this.params.fileContents()
60 | : this.params.fileContents.join('\n')
61 | if (this.local) {
62 | const cmd = ({ write: localWrite, append: localAppend })[this.op]
63 | return cmd(filePath, fileContents)
64 | } else {
65 | return ({ write, append })[this.op](conn, filePath, fileContents)
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/docs/assets/js/9.dca480d7.js:
--------------------------------------------------------------------------------
1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[9],{175:function(t,e,n){"use strict";n.r(e);var s=n(0),a=Object(s.a)({},function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"content"},[t._m(0),t._v(" "),n("p",[t._v("To install, use "),n("a",{attrs:{href:"https://www.npmjs.com/",target:"_blank",rel:"noopener noreferrer"}},[n("code",[t._v("npm")]),n("OutboundLink")],1),t._v(" or "),n("a",{attrs:{href:"https://yarnpkg.com/",target:"_blank",rel:"noopener noreferrer"}},[n("code",[t._v("yarn")]),n("OutboundLink")],1),t._v(":")]),t._v(" "),t._m(1),t._m(2),t._v(" "),t._m(3),t._v(" "),t._m(4),t._v(" "),n("p",[n("strong",[t._v("Fabula")]),t._v(" is currently in "),n("strong",[t._v("beta phase")]),t._v(", that is to say, we're still testing\non several projects and making sure all potential issues are addressed. Don't\nuse it in production just yet unless you're sure what you're doing. And please\n"),n("a",{attrs:{href:"https://github.com/nuxt/fabula/issues/new",target:"_blank",rel:"noopener noreferrer"}},[t._v("file an issue on Github"),n("OutboundLink")],1),t._v(" if you find anything that needs attention.")])])},[function(){var t=this.$createElement,e=this._self._c||t;return e("h1",{attrs:{id:"installation"}},[e("a",{staticClass:"header-anchor",attrs:{href:"#installation","aria-hidden":"true"}},[this._v("#")]),this._v(" Installation")])},function(){var t=this.$createElement,e=this._self._c||t;return e("div",{staticClass:"language-sh extra-class"},[e("pre",{pre:!0,attrs:{class:"language-text"}},[e("code",[this._v("$ npm install fabula -g\n$ yarn global add fabula\n")])])])},function(){var t=this.$createElement,e=this._self._c||t;return e("h2",{attrs:{id:"highlighting"}},[e("a",{staticClass:"header-anchor",attrs:{href:"#highlighting","aria-hidden":"true"}},[this._v("#")]),this._v(" Highlighting")])},function(){var t=this.$createElement,e=this._self._c||t;return e("p",[this._v("As new and experimental as "),e("strong",[this._v("Fabula")]),this._v(" is, there's no code editor or IDE plugin available providing specialized syntax highlighting. For the moment, just set\nyour editor's syntax settings to "),e("strong",[this._v("XML")]),this._v(" when editing "),e("strong",[this._v("Fabula")]),this._v(" files.")])},function(){var t=this.$createElement,e=this._self._c||t;return e("h2",{attrs:{id:"disclaimer"}},[e("a",{staticClass:"header-anchor",attrs:{href:"#disclaimer","aria-hidden":"true"}},[this._v("#")]),this._v(" Disclaimer")])}],!1,null,null,null);e.default=a.exports}}]);
--------------------------------------------------------------------------------
/dist/fabula-cd.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('fs');
4 | const path = require('path');
5 | require('consola');
6 | require('lodash.merge');
7 | require('util');
8 | require('read');
9 | require('ssh2');
10 | require('./fabula-chunk.js');
11 | require('module');
12 | require('os');
13 | require('lodash.template');
14 | const __chunk_2 = require('./fabula-chunk2.js');
15 | require('child_process');
16 | require('./fabula-chunk3.js');
17 | require('prompts');
18 |
19 | const cd = {
20 | name: 'cd',
21 | patterns: {
22 | block: /^(?:local\s*)?cd\s+(.+?):\s*$/,
23 | global: /^(?:local\s*)?cd\s+([^ :]+?)\s*$/
24 | },
25 | match(line) {
26 | this.dedent = 0;
27 | if (this.argv[0] === 'cd') {
28 | let match;
29 | // eslint-disable-next-line no-cond-assign
30 | if (match = line.match(this.cmd.patterns.block)) {
31 | this.block = true;
32 | return match
33 | // eslint-disable-next-line no-cond-assign
34 | } else if (match = line.match(this.cmd.patterns.global)) {
35 | this.global = true;
36 | return match
37 | }
38 | }
39 | },
40 | line(line) {
41 | if (this.firstLine) {
42 | if (this.global) {
43 | this.settings.$cwd = this.match[1];
44 | return false
45 | } else {
46 | this.params.cwd = this.match[1];
47 | this.params.commands = [];
48 | return true
49 | }
50 | } else if (!/^\s+/.test(line)) {
51 | return false
52 | } else {
53 | if (this.params.commands.length === 0) {
54 | const match = line.match(/^\s+/);
55 | if (match) {
56 | this.dedent = match[0].length;
57 | }
58 | }
59 | this.params.commands.push(line.slice(this.dedent));
60 | return true
61 | }
62 | },
63 | async command(conn, logger) {
64 | const settings = {
65 | ...this.settings,
66 | $cwd: path.resolve(
67 | this.settings.$cwd || process.cwd(),
68 | this.params.cwd
69 | )
70 | };
71 | const commands = this.params.commands.map((cmd) => {
72 | if (this.local && !/^\s+/.test(cmd)) {
73 | cmd = `local ${cmd}`;
74 | }
75 | return cmd
76 | }).join('\n');
77 | if (this.local) {
78 | await __chunk_2.runLocalSource(this.settings.$name, commands, settings, logger);
79 | } else {
80 | await __chunk_2.runSource(this.context.server, conn, this.settings.$name, commands, settings, logger);
81 | }
82 | }
83 | };
84 |
85 | exports.default = cd;
86 |
--------------------------------------------------------------------------------
/dist/fabula-ensure.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('fs');
4 | const path = require('path');
5 | require('consola');
6 | require('lodash.merge');
7 | require('util');
8 | require('read');
9 | require('ssh2');
10 | require('./fabula-chunk.js');
11 | require('module');
12 | require('os');
13 | require('lodash.template');
14 | const __chunk_2 = require('./fabula-chunk2.js');
15 | require('child_process');
16 | require('./fabula-chunk3.js');
17 | require('prompts');
18 |
19 | const ensure = {
20 | name: 'ensure',
21 | patterns: {
22 | block: /^(?:local\s*)?ensure\s+(.+?):\s*$/,
23 | global: /^(?:local\s*)?ensure\s+([^ :]+?)\s*$/
24 | },
25 | match(line) {
26 | this.dedent = 0;
27 | if (this.argv[0] === 'ensure') {
28 | let match;
29 | // eslint-disable-next-line no-cond-assign
30 | if (match = line.match(this.cmd.patterns.block)) {
31 | this.block = true;
32 | return match
33 | // eslint-disable-next-line no-cond-assign
34 | } else if (match = line.match(this.cmd.patterns.global)) {
35 | this.global = true;
36 | return match
37 | }
38 | }
39 | },
40 | line(line) {
41 | if (this.firstLine) {
42 | if (this.global) {
43 | this.settings.$cwd = this.match[1];
44 | return false
45 | } else {
46 | this.params.cwd = this.match[1];
47 | this.params.commands = [];
48 | return true
49 | }
50 | } else if (!/^\s+/.test(line)) {
51 | return false
52 | } else {
53 | if (this.params.commands.length === 0) {
54 | const match = line.match(/^\s+/);
55 | if (match) {
56 | this.dedent = match[0].length;
57 | }
58 | }
59 | this.params.commands.push(line.slice(this.dedent));
60 | return true
61 | }
62 | },
63 | async command(conn, logger) {
64 | const settings = {
65 | ...this.settings,
66 | fail: true,
67 | $cwd: path.resolve(
68 | this.settings.$cwd || process.cwd(),
69 | this.params.cwd
70 | )
71 | };
72 | const commands = this.params.commands.map((cmd) => {
73 | if (this.local && !/^\s+/.test(cmd)) {
74 | cmd = `local ${cmd}`;
75 | }
76 | return cmd
77 | }).join('\n');
78 | if (this.local) {
79 | await __chunk_2.runLocalSource(this.settings.$name, commands, settings, logger);
80 | } else {
81 | await __chunk_2.runSource(this.context.server, conn, this.settings.$name, commands, settings, logger);
82 | }
83 | }
84 | };
85 |
86 | exports.default = ensure;
87 |
--------------------------------------------------------------------------------
/src/commands/handle.js:
--------------------------------------------------------------------------------
1 |
2 | import merge from 'lodash.merge'
3 | import prompt from '../prompt'
4 | import { exec } from '../ssh'
5 | import { execLocal } from '../local'
6 | import { runSource, runLocalSource } from '../run'
7 |
8 | export default {
9 | name: 'handle',
10 | patterns: {
11 | block: /^(?:local\s*)?(.+?)\s*@([\w\d_]+):\s*$/,
12 | global: /^(?:local\s*)?(.+?)\s*@([\w\d_]+)\s*$/
13 | },
14 | match(line) {
15 | this.dedent = 0
16 | let match
17 | // eslint-disable-next-line no-cond-assign
18 | if (match = line.match(this.cmd.patterns.block)) {
19 | this.block = true
20 | this.params.cmd = match[1]
21 | this.handler = match[2]
22 | return match
23 | // eslint-disable-next-line no-cond-assign
24 | } else if (match = line.match(this.cmd.patterns.global)) {
25 | this.global = true
26 | this.params.cmd = match[1]
27 | this.handler = match[2]
28 | return match
29 | }
30 | },
31 | line(line) {
32 | if (this.firstLine) {
33 | if (this.global) {
34 | return false
35 | } else {
36 | this.params.commands = []
37 | return true
38 | }
39 | } else if (!/^\s+/.test(line)) {
40 | return false
41 | } else {
42 | if (this.params.commands.length === 0) {
43 | const match = line.match(/^\s+/)
44 | if (match) {
45 | this.dedent = match[0].length
46 | }
47 | }
48 | this.params.commands.push(line.slice(this.dedent))
49 | return true
50 | }
51 | },
52 | async command(conn, logger) {
53 | const settings = { ...this.settings }
54 | let result
55 | if (this.local) {
56 | this.argv.pop()
57 | result = await execLocal([this.argv[0], this.argv.slice(1)], this.env, this.settings.$cwd)
58 | } else {
59 | result = await exec(conn, this.params.cmd, this.env, this.settings.$cwd)
60 | }
61 | let abort = false
62 | const fabula = {
63 | prompt,
64 | abort: () => {
65 | abort = true
66 | }
67 | }
68 | if (this.handler && this.settings[this.handler]) {
69 | merge(settings, await this.settings[this.handler](result, fabula))
70 | }
71 | if (abort) {
72 | return false
73 | }
74 | const commands = this.params.commands.join('\n')
75 | if (this.context.server) {
76 | await runSource(this.context.server, conn, this.settings.$name, commands, settings, logger)
77 | } else {
78 | await runLocalSource(this.settings.$name, commands, settings, logger)
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/dist/fabula-write.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('fs');
4 | require('consola');
5 | require('util');
6 | require('read');
7 | require('ssh2');
8 | const __chunk_1 = require('./fabula-chunk.js');
9 | require('child_process');
10 | const __chunk_3 = require('./fabula-chunk3.js');
11 |
12 | const write = {
13 | patterns: {
14 | block: (argv) => {
15 | return new RegExp(`^(?:local\\s*)?${argv[0]}\\s+(.+?):$`)
16 | },
17 | string: (argv) => {
18 | return new RegExp(`^(?:local\\s*)?${argv[0]}\\s+([^ ]+?)\\s+([^ :]+?)$`)
19 | }
20 | },
21 | match(line) {
22 | this.op = this.argv[0];
23 | this.dedent = 0;
24 | if (['append', 'write'].includes(this.argv[0])) {
25 | let match;
26 | // eslint-disable-next-line no-cond-assign
27 | if (match = line.match(this.cmd.patterns.block(this.argv))) {
28 | this.block = true;
29 | return match
30 | // eslint-disable-next-line no-cond-assign
31 | } else if (match = line.match(this.cmd.patterns.string(this.argv))) {
32 | this.string = true;
33 | return match
34 | }
35 | }
36 | },
37 | line(line) {
38 | if (this.firstLine) {
39 | this.params.filePath = this.match[1];
40 | this.params.fileContents = [];
41 | if (this.string) {
42 | const settingsKey = this.match[2];
43 | this.params.fileContents = () => {
44 | // eslint-disable-next-line no-eval
45 | return eval(`this.settings.${settingsKey}`).split(/\n/g)
46 | };
47 | return false
48 | } else {
49 | return true
50 | }
51 | } else if (!/^\s+/.test(line)) {
52 | return false
53 | } else {
54 | if (this.params.fileContents.length === 0) {
55 | const match = line.match(/^\s+/);
56 | if (match) {
57 | this.dedent = match[0].length;
58 | }
59 | }
60 | this.params.fileContents.push(line.slice(this.dedent));
61 | return true
62 | }
63 | },
64 | command(conn) {
65 | const filePath = this.params.filePath;
66 | const fileContents = typeof this.params.fileContents === 'function'
67 | ? this.params.fileContents()
68 | : this.params.fileContents.join('\n');
69 | if (this.local) {
70 | const cmd = ({ write: __chunk_3.localWrite, append: __chunk_3.localAppend })[this.op];
71 | return cmd(filePath, fileContents)
72 | } else {
73 | return ({ write: __chunk_1.write, append: __chunk_1.append })[this.op](conn, filePath, fileContents)
74 | }
75 | }
76 | };
77 |
78 | exports.default = write;
79 |
--------------------------------------------------------------------------------
/src/ssh.js:
--------------------------------------------------------------------------------
1 |
2 | import { readFileSync } from 'fs'
3 | import { promisify } from 'util'
4 | import consola from 'consola'
5 | import read from 'read'
6 | import { Client } from 'ssh2'
7 |
8 | function exit(err) {
9 | consola.fatal(err.message || err)
10 | process.exit()
11 | }
12 |
13 | function askPassphrase(privateKey) {
14 | return new Promise((resolve) => {
15 | const prompt = {
16 | prompt: `${privateKey} requires a passphrase: `,
17 | silent: true,
18 | replace: '*'
19 | }
20 | read(prompt, (_, passphrase) => {
21 | resolve(passphrase)
22 | })
23 | })
24 | }
25 |
26 | function isKeyEncrypted(privateKey) {
27 | if (/^-*BEGIN ENCRYPTED PRIVATE KEY/g.test(privateKey)) {
28 | return true
29 | } else {
30 | const firstLines = privateKey.split(/\n/g).slice(0, 2)
31 | return firstLines.some(line => line.match(/Proc-Type: 4,ENCRYPTED/))
32 | }
33 | }
34 |
35 | export function getConnection(settings) {
36 | return new Promise(async (resolve, reject) => {
37 | const conn = new Client()
38 | conn.exec = promisify(conn.exec).bind(conn)
39 | conn.sftp = promisify(conn.sftp).bind(conn)
40 | conn.on('error', exit)
41 | conn.on('ready', () => {
42 | conn.settings = settings
43 | resolve(conn)
44 | })
45 |
46 | let connect
47 | if (settings.privateKey) {
48 | const privateKey = readFileSync(settings.privateKey).toString()
49 | if (!settings.passphrase && isKeyEncrypted(privateKey)) {
50 | settings.passphrase = await askPassphrase(settings.privateKey)
51 | }
52 | connect = () => conn.connect({ ...settings, privateKey })
53 | } else {
54 | if (!settings.agent) {
55 | settings.agent = process.env.SSH_AUTH_SOCK
56 | }
57 | connect = () => conn.connect({ ...settings })
58 | }
59 | await connect()
60 | })
61 | }
62 |
63 | export async function write(conn, filePath, fileContents) {
64 | const stream = await conn.sftp()
65 | return stream.writeFile(filePath, fileContents)
66 | }
67 |
68 | export async function append(conn, filePath, fileContents) {
69 | const stream = await conn.sftp()
70 | return stream.appendFile(filePath, fileContents)
71 | }
72 |
73 | export async function get(conn, remotePath, localPath) {
74 | const stream = await conn.sftp()
75 | return stream.fastGet(remotePath, localPath)
76 | }
77 |
78 | export async function put(conn, localPath, remotePath) {
79 | const stream = await conn.sftp()
80 | return stream.fastPut(localPath, remotePath)
81 | }
82 |
83 | export function exec(conn, cmd, env = {}) {
84 | return new Promise(async (resolve, reject) => {
85 | let stdout = ''
86 | let stderr = ''
87 | const stream = await conn.exec(cmd, { env })
88 | stream.on('close', (code, signal) => {
89 | resolve({ stdout, stderr, code, signal })
90 | })
91 | stream.on('data', (data) => { stdout += data })
92 | stream.stderr.on('data', (data) => { stderr += data })
93 | })
94 | }
95 |
--------------------------------------------------------------------------------
/src/cli.js:
--------------------------------------------------------------------------------
1 |
2 | import { existsSync } from 'fs'
3 | import { resolve } from 'path'
4 | import consola from 'consola'
5 | import arg from 'arg'
6 | import { run } from './run'
7 | import { active } from './logging'
8 |
9 | function resolvePath(path) {
10 | return resolve(process.cwd(), ...path.split('/'))
11 | }
12 |
13 | function activeStreams() {
14 | return new Promise((resolve) => {
15 | setTimeout(() => {
16 | if (!active) {
17 | resolve()
18 | } else {
19 | resolve(activeStreams())
20 | }
21 | }, 1)
22 | })
23 | }
24 |
25 | // const fabulaConfigHelper = () => {
26 |
27 | // }
28 |
29 | export async function loadConfig(rcFile = null) {
30 | let config
31 | if (rcFile === null) {
32 | for (rcFile of ['fabula.js', '.fabularc.js', '.fabularc']) {
33 | if (existsSync(resolvePath(rcFile))) {
34 | config = require(resolvePath(rcFile))
35 | break
36 | }
37 | rcFile = null
38 | }
39 | } else {
40 | config = require(rcFile)
41 | }
42 | if (rcFile === null) {
43 | consola.warn('No Fabula configuration file found.')
44 | config = {}
45 | }
46 | config = config.default || config
47 | if (typeof config === 'function') {
48 | config = await config()
49 | }
50 | return config
51 | }
52 |
53 | function showHelpAndExit() {
54 | process.stdout.write(
55 | '\n' +
56 | ' Usage: fabula (run on specified servers)\n' +
57 | ' fabula all (run on all servers)\n' +
58 | ' fabula (run local only)\n\n'
59 | )
60 | process.exit()
61 | }
62 |
63 | function ensureSource(source) {
64 | if (!source.endsWith('.fab')) {
65 | source = `${source}.fab`
66 | }
67 | if (!existsSync(source)) {
68 | consola.fatal(`Task source doesn't exist: ${source}.`)
69 | process.exit()
70 | }
71 | return source
72 | }
73 |
74 | export default async function () {
75 | const args = arg({})
76 | if (args._.length === 0 || args._[0] === 'help') {
77 | showHelpAndExit()
78 | }
79 | const config = await loadConfig()
80 | if (args._.length === 2) {
81 | // Run on remote servers:
82 | // fabula
34 |