@frakjs/frak 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.frakignore +0 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/frak.js +213 -0
- package/eslint.config.js +1 -0
- package/frak.config.js +10 -0
- package/package.json +28 -0
- package/src/ansi.js +77 -0
- package/src/cli.js +79 -0
- package/src/config.js +51 -0
- package/src/crypto.js +5 -0
- package/src/date.js +61 -0
- package/src/debug.js +10 -0
- package/src/diff.js +173 -0
- package/src/humanize.js +13 -0
- package/src/rsync.js +155 -0
- package/src/ssh.js +44 -0
package/.frakignore
ADDED
|
File without changes
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 frakjs
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Frak
|
|
2
|
+
|
|
3
|
+
**Frak** is an agentless deployment tool for web apps. Powered by `rsync` over SSH, it tracks file changes, creates patch backups, and supports dry-run diffs, interactive confirmations, and post-deploy hooks.
|
|
4
|
+
|
|
5
|
+
> βFrak it, ship it.β
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## π Features
|
|
10
|
+
|
|
11
|
+
- π **Init once, deploy anytime**
|
|
12
|
+
- π **Interactive diffs** before every push/pull
|
|
13
|
+
- π¦ **Patch-based backups** stored on your server
|
|
14
|
+
- π§Ύ **Post-deploy commands** (e.g. restart services)
|
|
15
|
+
- π‘ **SSH console access** into deploy root
|
|
16
|
+
- π **Pull support** for syncing back changes
|
|
17
|
+
- π **Backup inspection** with additions/deletions summary
|
|
18
|
+
|
|
19
|
+
## π¦ Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @frakjs/frak
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or run it on-demand with `npx`
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @frakjs/frak <command>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## π οΈ Setup
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
frak init
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This creates a `frak.config.js` file:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
export default {
|
|
41
|
+
server: "user@example.com",
|
|
42
|
+
root: "/var/www/html",
|
|
43
|
+
after: "npm run restart" // optional post-deploy hook
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## π§© Commands
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
frak init # Create config file
|
|
51
|
+
frak console # SSH into the deploy root
|
|
52
|
+
frak diff # Show dry-run changes
|
|
53
|
+
frak push # Deploy changes (with confirmation)
|
|
54
|
+
frak pull # Download changes from remote
|
|
55
|
+
frak backups:list # List previous patch deployments
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## π Authentication
|
|
59
|
+
|
|
60
|
+
Frak uses SSH. Make sure your public key is in `~/.ssh/authorized_keys` on the remote server.
|
|
61
|
+
|
|
62
|
+
## π§ͺ Example: Deployment Flow
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
frak diff # See what will change
|
|
66
|
+
frak push # Sync files, confirm, deploy
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Behind the scenes:
|
|
70
|
+
|
|
71
|
+
- Files are compared with a dry-run rsync
|
|
72
|
+
- If approved, a patch file is generated and saved
|
|
73
|
+
- Files are synced live
|
|
74
|
+
- Optional after script is run remotely
|
|
75
|
+
|
|
76
|
+
## πΎ Backups
|
|
77
|
+
|
|
78
|
+
Each deployment saves a `.patch` file on the server under `.backups/`. You can list them with:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
frak backups:list
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Output shows:
|
|
85
|
+
|
|
86
|
+
- Timestamp
|
|
87
|
+
- Filename
|
|
88
|
+
- Additions and deletions in the patch
|
|
89
|
+
|
|
90
|
+
## π§ Design Philosophy
|
|
91
|
+
|
|
92
|
+
- Minimal dependencies
|
|
93
|
+
- Pure CLI
|
|
94
|
+
- Patch-based history
|
|
95
|
+
- No Docker or Node on server
|
|
96
|
+
- Human-friendly
|
|
97
|
+
|
|
98
|
+
## π§° Advanced Config (optional)
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
export default {
|
|
102
|
+
server: 'user@host',
|
|
103
|
+
root: '/srv/www',
|
|
104
|
+
after: 'sudo systemctl reload nginx',
|
|
105
|
+
|
|
106
|
+
dev: {
|
|
107
|
+
root: '/srv/www-dev',
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
production: {
|
|
111
|
+
root: '/srv/www-prod',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
frak push # uses default root-level properties
|
|
118
|
+
frak push env=dev # uses properties in "dev" to override
|
|
119
|
+
frak push env=production # uses properties in "production" to override
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## π Debugging
|
|
123
|
+
|
|
124
|
+
Enable debug output by setting `DEBUG=1`:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
DEBUG=1 frak push
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## π License
|
|
131
|
+
|
|
132
|
+
MIT
|
|
133
|
+
|
|
134
|
+
## π§βπ» Author
|
|
135
|
+
|
|
136
|
+
Built by [Frank Strube](https://github.com/fstrube)
|
|
137
|
+
|
|
138
|
+
Contributions and suggestions welcome!
|
package/bin/frak.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync, spawn } from 'node:child_process';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import * as ansi from '../src/ansi.js';
|
|
8
|
+
import * as cli from '../src/cli.js';
|
|
9
|
+
import config, { load as loadConfig } from '../src/config.js';
|
|
10
|
+
import { sha256 } from '../src/crypto.js';
|
|
11
|
+
import debug from '../src/debug.js';
|
|
12
|
+
import diff from '../src/diff.js';
|
|
13
|
+
import humanize from '../src/humanize.js';
|
|
14
|
+
import * as rsync from '../src/rsync.js';
|
|
15
|
+
import * as ssh from '../src/ssh.js';
|
|
16
|
+
import * as date from '../src/date.js';
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Try to load config. If it fails, print error message in red.
|
|
22
|
+
*
|
|
23
|
+
* @param {String} env
|
|
24
|
+
*/
|
|
25
|
+
async function tryLoadConfig(env) {
|
|
26
|
+
try {
|
|
27
|
+
await loadConfig(env);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.log(ansi.red(e.message));
|
|
30
|
+
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Print command usage instructions.
|
|
39
|
+
*/
|
|
40
|
+
function usage() {
|
|
41
|
+
console.log('Usage: frak <command>');
|
|
42
|
+
console.log('Commands:');
|
|
43
|
+
console.log(' init Initialize the current directory for frak deployment');
|
|
44
|
+
console.log(' console Connect to the remote server via SSH');
|
|
45
|
+
console.log(' diff Show differences between local and remote files');
|
|
46
|
+
console.log(' pull Download the remote file(s)');
|
|
47
|
+
console.log(' push Deploy website via rsync');
|
|
48
|
+
console.log(' backups:list List backups on the server');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Execute the command.
|
|
53
|
+
*
|
|
54
|
+
* @param {Array} args
|
|
55
|
+
*/
|
|
56
|
+
async function main(args) {
|
|
57
|
+
const { command, options } = cli.parse(args.slice(2));
|
|
58
|
+
|
|
59
|
+
debug('parsed:', { command, options });
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
// Initialize Frak in the current directory
|
|
63
|
+
if (command === 'init') {
|
|
64
|
+
const configPath = path.join(process.cwd(), 'frak.config.js');
|
|
65
|
+
|
|
66
|
+
if (!existsSync(configPath)) {
|
|
67
|
+
writeFileSync(configPath, `export default ${JSON.stringify({ server: 'example.com', root: '/var/www/html' }, null, 4)}`);
|
|
68
|
+
console.info(ansi.green('Configuration file created: frak.config.js'));
|
|
69
|
+
} else {
|
|
70
|
+
console.error(ansi.red('Frak is already initialized in this directory.'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Start a console session, or run the provided command on
|
|
74
|
+
// the remote server.
|
|
75
|
+
} else if (command === 'console') {
|
|
76
|
+
await tryLoadConfig(options.env);
|
|
77
|
+
|
|
78
|
+
if (!config.server) {
|
|
79
|
+
console.error('No remote server configured. Add "server" to frak.config.js');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const cmd = options.command ?? '\\$SHELL -l -i';
|
|
85
|
+
|
|
86
|
+
execSync(`ssh -t ${config.server} -- cd ${config.root} \\&\\& ${cmd}`, { stdio: 'inherit' });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
debug(e)
|
|
89
|
+
}
|
|
90
|
+
// Show the differences between local files and remote server
|
|
91
|
+
} else if (command === 'diff') {
|
|
92
|
+
await tryLoadConfig(options.env);
|
|
93
|
+
|
|
94
|
+
const { output } = await rsync.exec('--dry-run');
|
|
95
|
+
|
|
96
|
+
await diff(output, options);
|
|
97
|
+
|
|
98
|
+
// Push or pull changes to remote server
|
|
99
|
+
} else if (command === 'push' || command === 'pull') {
|
|
100
|
+
await tryLoadConfig(options.env);
|
|
101
|
+
|
|
102
|
+
// Execute dry-run
|
|
103
|
+
const { output } = await rsync.exec('--dry-run');
|
|
104
|
+
|
|
105
|
+
console.log(humanize(output));
|
|
106
|
+
|
|
107
|
+
if (command === 'push' && config.after) {
|
|
108
|
+
console.log(ansi.blue('Run command:'), config.after, '\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const proceed = await cli.agree(ansi.yellow('The above actions will be taken. Continue? (This cannot be undone): '));
|
|
112
|
+
|
|
113
|
+
if (!proceed) {
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Prepare patch file
|
|
118
|
+
let patch;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
patch = await diff(output, { interactive: false });
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error(e);
|
|
124
|
+
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Execute actual rsync command
|
|
129
|
+
const { backupPath, output: actualOutput } = await rsync.exec();
|
|
130
|
+
|
|
131
|
+
console.log('');
|
|
132
|
+
console.log(humanize(actualOutput));
|
|
133
|
+
|
|
134
|
+
// Place patch file in backup dir
|
|
135
|
+
const hash = sha256(patch).slice(0, 7);
|
|
136
|
+
const copy = spawn('ssh', [config.server, '--', `cat > '${config.root || '.'}/${backupPath}/${hash}.patch'`]);
|
|
137
|
+
|
|
138
|
+
copy.stdout.on('data', (data) => debug('' + data));
|
|
139
|
+
copy.stderr.on('data', (data) => debug('' + data));
|
|
140
|
+
copy.stdin.write(patch);
|
|
141
|
+
|
|
142
|
+
// Execute "after" command
|
|
143
|
+
if (command === 'push' && config.after) {
|
|
144
|
+
console.log(ansi.yellow('Executing commands on remote server.'));
|
|
145
|
+
|
|
146
|
+
debug({ command: config.after });
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await ssh.exec(config.after, { server: config.server, root: config.root });
|
|
150
|
+
} catch (e) {
|
|
151
|
+
debug(e);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// TODO Webhook
|
|
156
|
+
|
|
157
|
+
if (command === 'push') {
|
|
158
|
+
console.log(ansi.green('Deployment complete.'));
|
|
159
|
+
}
|
|
160
|
+
} else if (command === 'backups:list') {
|
|
161
|
+
await tryLoadConfig(options.env);
|
|
162
|
+
|
|
163
|
+
const delimeter = '------Separator--';
|
|
164
|
+
const grep = {
|
|
165
|
+
additions: `grep -e '^+' "$NAME"/*.patch | grep -v '^+++' | wc -l`,
|
|
166
|
+
deletions: `grep -e '^-' "$NAME"/*.patch | grep -v '^---' | wc -l`,
|
|
167
|
+
}
|
|
168
|
+
const ls = `stat --format='%Y %n' .backups/* | sort -r`;
|
|
169
|
+
|
|
170
|
+
const list = ssh.exec(`${ls} | xargs -I {} sh -c 'TIMESTAMP=$(echo {} | cut -d" " -f1); NAME=$(echo {} | cut -d" " -f2-); echo $TIMESTAMP${delimeter}$NAME${delimeter}$(${grep.additions})${delimeter}$(${grep.deletions})'`, { quiet: true, server: config.server, root: config.root });
|
|
171
|
+
let backups = '';
|
|
172
|
+
|
|
173
|
+
list.stdout.on('data', data => backups += data);
|
|
174
|
+
|
|
175
|
+
await list;
|
|
176
|
+
|
|
177
|
+
const table = new cli.Table([
|
|
178
|
+
{
|
|
179
|
+
header: 'Name',
|
|
180
|
+
width: 55,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
header: 'Date',
|
|
184
|
+
width: 25,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
header: 'Changes',
|
|
188
|
+
width: 30,
|
|
189
|
+
}
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
backups.trim().split(/\n/).forEach(backup => {
|
|
193
|
+
const [ timestamp, name, additions, deletions ] = backup.split(delimeter);
|
|
194
|
+
table.row([
|
|
195
|
+
name.replace(/^.backups\//, ''),
|
|
196
|
+
date.relative(timestamp * 1000),
|
|
197
|
+
ansi.green(`${additions} addition${additions == 1 ? '' : 's'}`) + ', ' + ansi.red(`${deletions} deletion${deletions == 1 ? '' : 's'}`),
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
table.print();
|
|
202
|
+
} else {
|
|
203
|
+
usage();
|
|
204
|
+
|
|
205
|
+
if (command !== '--help') {
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
main(process.argv);
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default [{}]
|
package/frak.config.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@frakjs/frak",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A commandline deployment tool for syncing files to remote servers.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"frak": "bin/frak.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/frakjs/frak.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"rsync",
|
|
19
|
+
"deployment",
|
|
20
|
+
"ssh"
|
|
21
|
+
],
|
|
22
|
+
"author": "Frank Strube <contact@frakjs.com>",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/frakjs/frak/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/frakjs/frak#readme"
|
|
28
|
+
}
|
package/src/ansi.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const ansi = {
|
|
2
|
+
CLEAR: '\u001b[0m',
|
|
3
|
+
BOLD: '\u001b[1m',
|
|
4
|
+
UNDERLINE: '\u001b[4m',
|
|
5
|
+
|
|
6
|
+
BLACK: '\u001b[30m',
|
|
7
|
+
GRAY: '\u001b[90m',
|
|
8
|
+
RED: '\u001b[31m',
|
|
9
|
+
GREEN: '\u001b[32m',
|
|
10
|
+
YELLOW: '\u001b[33m',
|
|
11
|
+
BLUE: '\u001b[34m',
|
|
12
|
+
MAGENTA: '\u001b[35m',
|
|
13
|
+
CYAN: '\u001b[36m',
|
|
14
|
+
WHITE: '\u001b[37m',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function strip(string) {
|
|
18
|
+
return string
|
|
19
|
+
.replace(new RegExp(ansi.CLEAR.replace('[', '\\['), 'g'), '')
|
|
20
|
+
.replace(new RegExp(ansi.BOLD.replace('[', '\\['), 'g'), '')
|
|
21
|
+
.replace(new RegExp(ansi.UNDERLINE.replace('[', '\\['), 'g'), '')
|
|
22
|
+
.replace(new RegExp(ansi.BLACK.replace('[', '\\['), 'g'), '')
|
|
23
|
+
.replace(new RegExp(ansi.GRAY.replace('[', '\\['), 'g'), '')
|
|
24
|
+
.replace(new RegExp(ansi.RED.replace('[', '\\['), 'g'), '')
|
|
25
|
+
.replace(new RegExp(ansi.GREEN.replace('[', '\\['), 'g'), '')
|
|
26
|
+
.replace(new RegExp(ansi.YELLOW.replace('[', '\\['), 'g'), '')
|
|
27
|
+
.replace(new RegExp(ansi.BLUE.replace('[', '\\['), 'g'), '')
|
|
28
|
+
.replace(new RegExp(ansi.MAGENTA.replace('[', '\\['), 'g'), '')
|
|
29
|
+
.replace(new RegExp(ansi.CYAN.replace('[', '\\['), 'g'), '')
|
|
30
|
+
.replace(new RegExp(ansi.WHITE.replace('[', '\\['), 'g'), '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function bold(string) {
|
|
34
|
+
return `${ansi.BOLD}${string}${ansi.CLEAR}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function underline(string) {
|
|
38
|
+
return `${ansi.UNDERLINE}${string}${ansi.CLEAR}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function black(string) {
|
|
42
|
+
return `${ansi.BLACK}${string}${ansi.CLEAR}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function gray(string) {
|
|
46
|
+
return `${ansi.GRAY}${string}${ansi.CLEAR}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function red(string) {
|
|
50
|
+
return `${ansi.RED}${string}${ansi.CLEAR}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function green(string) {
|
|
54
|
+
return `${ansi.GREEN}${string}${ansi.CLEAR}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function yellow(string) {
|
|
58
|
+
return `${ansi.YELLOW}${string}${ansi.CLEAR}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function blue(string) {
|
|
62
|
+
return `${ansi.BLUE}${string}${ansi.CLEAR}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function magenta(string) {
|
|
66
|
+
return `${ansi.MAGENTA}${string}${ansi.CLEAR}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function cyan(string) {
|
|
70
|
+
return `${ansi.CYAN}${string}${ansi.CLEAR}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function white(string) {
|
|
74
|
+
return `${ansi.WHITE}${string}${ansi.CLEAR}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default ansi;
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as ansi from './ansi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse the arguments into command and options.
|
|
5
|
+
*
|
|
6
|
+
* @param {Array} args
|
|
7
|
+
* @returns {{command: String, options: Object}}
|
|
8
|
+
*/
|
|
9
|
+
export function parse(args) {
|
|
10
|
+
let command = 'push';
|
|
11
|
+
const options = {
|
|
12
|
+
colorize: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
for (let arg of args) {
|
|
16
|
+
if (arg.includes('=')) {
|
|
17
|
+
const [option, value] = arg.split('=', 2);
|
|
18
|
+
|
|
19
|
+
options[option] = value;
|
|
20
|
+
} else {
|
|
21
|
+
command = arg;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { command, options };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function agree(string) {
|
|
29
|
+
process.stdout.write(string);
|
|
30
|
+
|
|
31
|
+
const answer = await new Promise((resolve, reject) => {
|
|
32
|
+
process.stdin.on('data', data => resolve(data));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return ['y', 'yes'].includes(`${answer}`.trim().toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class Table {
|
|
39
|
+
constructor(columns) {
|
|
40
|
+
this.columns = columns;
|
|
41
|
+
this.rows = [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
row(cells) {
|
|
45
|
+
this.rows.push(cells);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
print() {
|
|
49
|
+
const { border, header } = this.columns.reduce(({ header, border }, column, i) => {
|
|
50
|
+
const width = ansi.strip(column.header).length;
|
|
51
|
+
const padding = Math.max(0, column.width - width - 2);
|
|
52
|
+
const paddingString = (new Array(padding + 1)).join(' ');
|
|
53
|
+
const borderString = (new Array(column.width + 1).join('-'));
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
border: `${border || ''}+${borderString}${i === this.columns.length - 1 ? '+' : ''}`,
|
|
57
|
+
header: `${header || ''}${ansi.gray('|')} ${ansi.bold(column.header)}${paddingString} ${i === this.columns.length - 1 ? ansi.gray('|') : ''}`,
|
|
58
|
+
};
|
|
59
|
+
}, {});
|
|
60
|
+
|
|
61
|
+
console.log(ansi.gray(border));
|
|
62
|
+
console.log(header);
|
|
63
|
+
console.log(ansi.gray(border));
|
|
64
|
+
|
|
65
|
+
this.rows.forEach(r => {
|
|
66
|
+
const rowString = r.reduce((row, cell, i) => {
|
|
67
|
+
const width = ansi.strip(cell).length
|
|
68
|
+
const padding = Math.max(0, this.columns[i].width - width - 2);
|
|
69
|
+
const paddingString = (new Array(padding + 1)).join(' ');
|
|
70
|
+
const borderString = (new Array(this.columns[i].width + 1).join('-'));
|
|
71
|
+
|
|
72
|
+
return `${row || ''}${ansi.gray('|')} ${cell}${paddingString} ${i === r.length - 1 ? ansi.gray('|') : ''}`
|
|
73
|
+
}, '');
|
|
74
|
+
|
|
75
|
+
console.log(rowString);
|
|
76
|
+
console.log(ansi.gray(border));
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Cached config object. The config is stored in here so it can
|
|
5
|
+
// be loaded once and imported across all modules.
|
|
6
|
+
let config = {};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Locate a config file in the current working directory or its parents.
|
|
10
|
+
*
|
|
11
|
+
* @returns {String}
|
|
12
|
+
*/
|
|
13
|
+
export function locate() {
|
|
14
|
+
for (let dir = process.cwd(); dir != '/'; dir = path.dirname(dir)) {
|
|
15
|
+
const configPath = path.join(dir, 'frak.config.js');
|
|
16
|
+
|
|
17
|
+
if (fs.existsSync(configPath)) {
|
|
18
|
+
return configPath;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load the config values.
|
|
27
|
+
*
|
|
28
|
+
* @returns {Object}
|
|
29
|
+
*/
|
|
30
|
+
export async function load(env = null) {
|
|
31
|
+
const configPath = locate();
|
|
32
|
+
|
|
33
|
+
if (fs.existsSync(configPath)) {
|
|
34
|
+
Object.assign(config, (await import(configPath)).default);
|
|
35
|
+
|
|
36
|
+
if (env in config) {
|
|
37
|
+
return Object.assign(config, {...config[env]});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (env) {
|
|
41
|
+
throw new Error(`Missing configuration for environment "${env}"!`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return config;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
throw new Error('Config file not found! Try running frak init.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default config;
|
|
51
|
+
|
package/src/crypto.js
ADDED
package/src/date.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
function plural(string, value) {
|
|
2
|
+
return `${string}${value === 1 ? '' : 's'}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function relative(date) {
|
|
6
|
+
const datetime = new Date(date);
|
|
7
|
+
const now = new Date();
|
|
8
|
+
const months = [
|
|
9
|
+
'Jan',
|
|
10
|
+
'Feb',
|
|
11
|
+
'Mar',
|
|
12
|
+
'Apr',
|
|
13
|
+
'May',
|
|
14
|
+
'Jun',
|
|
15
|
+
'Jul',
|
|
16
|
+
'Aug',
|
|
17
|
+
'Sep',
|
|
18
|
+
'Oct',
|
|
19
|
+
'Nov',
|
|
20
|
+
'Dec',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
let seconds = Math.abs((datetime - now) / 1000);
|
|
24
|
+
let agoOrFromNow = datetime > now ? 'from now' : 'ago';
|
|
25
|
+
let unit = 'second';
|
|
26
|
+
let value = 0;
|
|
27
|
+
|
|
28
|
+
if (seconds < 30) {
|
|
29
|
+
value = 'just';
|
|
30
|
+
unit = 'now';
|
|
31
|
+
agoOrFromNow = '';
|
|
32
|
+
// Less than a minute
|
|
33
|
+
} else if (seconds < 60) {
|
|
34
|
+
value = Math.floor(seconds);
|
|
35
|
+
unit = plural('second', value);
|
|
36
|
+
// Less than an hour
|
|
37
|
+
} else if (seconds < 60 * 60) {
|
|
38
|
+
value = Math.floor(seconds / 60);
|
|
39
|
+
unit = plural('minute', value);
|
|
40
|
+
// Less than a day
|
|
41
|
+
} else if (seconds < 60 * 60 * 24) {
|
|
42
|
+
value = Math.floor(seconds / 60 / 60);
|
|
43
|
+
unit = plural('hour', value);
|
|
44
|
+
// Less than a week
|
|
45
|
+
} else if (seconds < 60 * 60 * 24 * 7) {
|
|
46
|
+
value = Math.floor(seconds / 60 / 60 / 24);
|
|
47
|
+
unit = plural('day', value);
|
|
48
|
+
// Less than a month
|
|
49
|
+
} else if (seconds < 60 * 60 * 24 * 30) {
|
|
50
|
+
value = Math.floor(seconds / 60 / 60 / 24 / 7);
|
|
51
|
+
unit = plural('week', value);
|
|
52
|
+
// Less than a year
|
|
53
|
+
} else if (seconds < 60 * 60 * 24 * 365) {
|
|
54
|
+
value = Math.floor(seconds / 60 / 60 / 24 / 30);
|
|
55
|
+
unit = plural('month', value);
|
|
56
|
+
} else {
|
|
57
|
+
return `${months[datetime.getMonth()]} ${datetime.getDay()}, ${datetime.getFullYear()} @ ${datetime.getHours() % 12}:${`0${datetime.getMinutes()}`.slice(-2)} ${datetime.getHours() >= 12 ? 'pm' : 'am'}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${value} ${unit} ${agoOrFromNow}`.trim();
|
|
61
|
+
}
|
package/src/debug.js
ADDED
package/src/diff.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import * as ansi from './ansi.js';
|
|
4
|
+
import config from './config.js';
|
|
5
|
+
import debug from './debug.js';
|
|
6
|
+
|
|
7
|
+
export default async function diff(dryRunOutput, options) {
|
|
8
|
+
// Default options
|
|
9
|
+
options = {
|
|
10
|
+
interactive: true,
|
|
11
|
+
...options,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Get changes from an rsync dry-run
|
|
15
|
+
const changes = dryRunOutput
|
|
16
|
+
.split(/\r?\n/)
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.map(line => {
|
|
19
|
+
// Split line into action and file, preserving whitespace for filenames
|
|
20
|
+
const [action, ...parts] = line.split(/\s+/);
|
|
21
|
+
|
|
22
|
+
let file, target;
|
|
23
|
+
|
|
24
|
+
// Links also have a target
|
|
25
|
+
if (action === 'cL+++++++++' || action === 'cLc.T......') {
|
|
26
|
+
const index = parts.indexOf('->');
|
|
27
|
+
|
|
28
|
+
debug({ parts })
|
|
29
|
+
|
|
30
|
+
file = parts.slice(0, index).join(' ');
|
|
31
|
+
target = parts.slice(index + 1).join(' ');
|
|
32
|
+
} else {
|
|
33
|
+
file = parts.join(' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [action, file, target];
|
|
37
|
+
})
|
|
38
|
+
.filter(([action, file]) => file.slice(-1) !== '/');
|
|
39
|
+
|
|
40
|
+
debug({ changes })
|
|
41
|
+
|
|
42
|
+
let proceed = true;
|
|
43
|
+
|
|
44
|
+
if (changes.length >= 50 && options.interactive) {
|
|
45
|
+
proceed = await cli.agree(ansi.yellow(`${changes.length} file(s) will be compared. Continue?`))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!proceed) {
|
|
49
|
+
return Promise.reject('Process canceled by user');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const files = changes.map(([action, file]) => file);
|
|
53
|
+
|
|
54
|
+
const diffs = changes.sort((a, b) => a[1].localeCompare(b[1])).map(change => {
|
|
55
|
+
const [action, file] = change;
|
|
56
|
+
const header = [
|
|
57
|
+
`diff --git a/${file} b/${file}`,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
let diff = [];
|
|
61
|
+
|
|
62
|
+
if (action === '*deleting') {
|
|
63
|
+
header.push('deleted file mode 100644');
|
|
64
|
+
header.push('index 0000000..0000000');
|
|
65
|
+
|
|
66
|
+
diff = ['diff', '-Naurp', `--label=a/${file}`, `--label=/dev/null`, file, '/dev/null'];
|
|
67
|
+
} else if (action === '<f+++++++++') {
|
|
68
|
+
const { mode } = statSync(file);
|
|
69
|
+
|
|
70
|
+
header.push(`new file mode ${mode.toString(8)}`);
|
|
71
|
+
header.push('index 0000000..0000000');
|
|
72
|
+
|
|
73
|
+
diff = ['diff', '-Naurp', `--label=/dev/null`, `--label=b/${file}`, '/dev/null', `<(tar xzOf $tarball --warning=no-unknown-keyword ${file} || cat /dev/null)`];
|
|
74
|
+
} else if (action === 'cLc.T......') {
|
|
75
|
+
const target = change[2];
|
|
76
|
+
|
|
77
|
+
header.push('index 0000000..0000000');
|
|
78
|
+
|
|
79
|
+
diff = ['diff', '-Naurp', `--label=a/${file}`, `--label=b/${file}`, `<(printf %s $(readlink ${file}))`, `<(printf %s ${target})`];
|
|
80
|
+
} else if (action === 'cL+++++++++') {
|
|
81
|
+
const target = change[2];
|
|
82
|
+
|
|
83
|
+
header.push('new file mode 120000');
|
|
84
|
+
header.push('index 0000000..0000000');
|
|
85
|
+
|
|
86
|
+
diff = ['diff', '-Naurp', `--label=/dev/null`, `--label=b/${file}`, '/dev/null', `<(printf %s ${target})`];
|
|
87
|
+
} else {
|
|
88
|
+
const { mode } = statSync(file);
|
|
89
|
+
|
|
90
|
+
header.push(`index 0000000..0000000 ${mode.toString(8)}`);
|
|
91
|
+
|
|
92
|
+
diff = ['diff', '-Naurp', `--label=a/${file}`, `--label=b/${file}`, file, `<(tar xzOf $tarball --warning=no-unknown-keyword ${file} || cat /dev/null)`];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [`printf "%s\\n" "${header.join('\n')}"`, ';', ...diff, ';'];
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (diffs.length > 0) {
|
|
99
|
+
debug({ diffs, files });
|
|
100
|
+
|
|
101
|
+
// Tarball the local files up to the server
|
|
102
|
+
const tar = spawn('tar', ['cz', ...files]);
|
|
103
|
+
const ssh = spawn('ssh', [
|
|
104
|
+
config.server,
|
|
105
|
+
'--',
|
|
106
|
+
'tarball=$(mktemp /tmp/frak-diff-tarball.XXXXXXXX);',
|
|
107
|
+
'cat > $tarball;',
|
|
108
|
+
`cd ${config.root};`,
|
|
109
|
+
...diffs.reduce((commands, diff) => commands.concat(...diff), []),
|
|
110
|
+
// `${diffs.join(';')};`,
|
|
111
|
+
// 'rm $tarball',
|
|
112
|
+
])
|
|
113
|
+
tar.stdout.pipe(ssh.stdin);
|
|
114
|
+
|
|
115
|
+
let less;
|
|
116
|
+
|
|
117
|
+
if (options.interactive) {
|
|
118
|
+
less = spawn('less', ['-r'], { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (options.interactive && options.colorize) {
|
|
122
|
+
const colorize = spawn('sed', [
|
|
123
|
+
// headers
|
|
124
|
+
'-e', `s/^\\(diff --git .*\\)/${ansi.bold('\\1')}/`,
|
|
125
|
+
'-e', `s/^\\(index .*\\)/${ansi.bold('\\1')}/`,
|
|
126
|
+
'-e', `s/^\\(new file mode .*\\)/${ansi.bold('\\1')}/`,
|
|
127
|
+
'-e', `s/^\\(deleted file mode .*\\)/${ansi.bold('\\1')}/`,
|
|
128
|
+
'-e', `s/^\\(--- .*\\)/${ansi.bold('\\1')}/`,
|
|
129
|
+
'-e', `s/^\\(+++ .*\\)/${ansi.bold('\\1')}/`,
|
|
130
|
+
// additions
|
|
131
|
+
'-e', `s/^\\(+[^+].*\\)/${ansi.green('\\1')}/`,
|
|
132
|
+
'-e', `s/^\\(+\\)$/${ansi.green('\\1')}/`,
|
|
133
|
+
// deletions
|
|
134
|
+
'-e', `s/^\\(-[^-].*\\)/${ansi.red('\\1')}/`,
|
|
135
|
+
'-e', `s/^\\(-\\)$/${ansi.red('\\1')}/`,
|
|
136
|
+
// markers
|
|
137
|
+
'-e', `s/^\\(@@ -[0-9][0-9]*,[0-9][0-9]* +[0-9][0-9]*,[0-9][0-9]* .*@@\\)/${ansi.blue('\\1')}/`,
|
|
138
|
+
'-e', `s/^\\(@@ -[0-9][0-9]*,[0-9][0-9]* +[0-9][0-9]* .*@@\\)/${ansi.blue('\\1')}/`,
|
|
139
|
+
'-e', `s/^\\(@@ -[0-9][0-9]* +[0-9][0-9]*,[0-9][0-9]* .*@@\\)/${ansi.blue('\\1')}/`,
|
|
140
|
+
]);
|
|
141
|
+
ssh.stdout.pipe(colorize.stdin);
|
|
142
|
+
colorize.stdout.pipe(less.stdin);
|
|
143
|
+
} else if (options.interactive) {
|
|
144
|
+
ssh.stdout.pipe(less.stdin);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (options.interactive) {
|
|
148
|
+
await new Promise((resolve, reject) => {
|
|
149
|
+
less.on('exit', (code) => {
|
|
150
|
+
if (code === 0) {
|
|
151
|
+
resolve();
|
|
152
|
+
} else {
|
|
153
|
+
reject(`exited with code ${code}`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
return await new Promise((resolve, reject) => {
|
|
159
|
+
let diff = '';
|
|
160
|
+
|
|
161
|
+
ssh.stdout.on('data', (data) => diff += data);
|
|
162
|
+
|
|
163
|
+
ssh.stderr.pipe(process.stderr);
|
|
164
|
+
|
|
165
|
+
ssh.on('exit', (code) => {
|
|
166
|
+
resolve(diff);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} else if (options.interactive) {
|
|
171
|
+
console.log(ansi.yellow('No changes detected.'));
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/humanize.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as ansi from './ansi.js';
|
|
2
|
+
|
|
3
|
+
export default function humanize(changes) {
|
|
4
|
+
return changes
|
|
5
|
+
.replace(/cL\+\+\+\+\+\+\+\+\+/g, ansi.blue('New Link '))
|
|
6
|
+
.replace(/<f\+\+\+\+\+\+\+\+\+/g, ansi.green('New File '))
|
|
7
|
+
.replace(/>f\+\+\+\+\+\+\+\+\+/g, ansi.green('New File '))
|
|
8
|
+
.replace(/cd\+\+\+\+\+\+\+\+\+/g, ansi.green('New Folder '))
|
|
9
|
+
.replace(/<fc..\.\.\.\.\.\./g, ansi.yellow('Update '))
|
|
10
|
+
.replace(/>fc..\.\.\.\.\.\./g, ansi.yellow('Update '))
|
|
11
|
+
.replace(/cLc\.T\.\.\.\.\.\./g, ansi.yellow('Update '))
|
|
12
|
+
.replace(/\*deleting /g, ansi.red('Delete '))
|
|
13
|
+
}
|
package/src/rsync.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parse } from './cli.js';
|
|
5
|
+
import config, { locate as locateConfig } from './config.js';
|
|
6
|
+
import debug from './debug.js';
|
|
7
|
+
|
|
8
|
+
let backupPath = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate rsync filters.
|
|
12
|
+
*/
|
|
13
|
+
export function filters() {
|
|
14
|
+
const { options } = parse(process.argv.slice(2));
|
|
15
|
+
const filters = [];
|
|
16
|
+
const includes = [];
|
|
17
|
+
const excludes = [
|
|
18
|
+
'.frak*',
|
|
19
|
+
'.git*',
|
|
20
|
+
'.svn*',
|
|
21
|
+
'.tags*',
|
|
22
|
+
'Capfile',
|
|
23
|
+
'Gemfile*',
|
|
24
|
+
'Vagrantfile*',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// merge excludes via commandline
|
|
28
|
+
if (options.ignore) {
|
|
29
|
+
excludes.push.apply(excludes, options.ignore.split(/ +/));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// merge path
|
|
33
|
+
if (options.path) {
|
|
34
|
+
const paths = options.path.split(/ +/)
|
|
35
|
+
.map(p => path.resolve(p))
|
|
36
|
+
.map(p => p) // TODO glob // paths.map! { |p| p[/[*?]/] ? Dir.glob(p) : p }
|
|
37
|
+
.flat(Infinity)
|
|
38
|
+
// make paths relative to .frak file
|
|
39
|
+
.map(p => p.replace(path.dirname(locateConfig()), ''));
|
|
40
|
+
|
|
41
|
+
// include all parent directories to fix recursive rsync from excluding all
|
|
42
|
+
paths.forEach(p => {
|
|
43
|
+
includes.push(p);
|
|
44
|
+
|
|
45
|
+
while (path.dirname(p) !== '/') {
|
|
46
|
+
excludes.push(`${path.dirname(p)}/*`);
|
|
47
|
+
|
|
48
|
+
p = path.dirname(p);
|
|
49
|
+
includes.push(p)
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
excludes.push('/*');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// merge .frakignore file
|
|
57
|
+
if (fs.existsSync(path.resolve(locateConfig(), '../.frakignore'))) {
|
|
58
|
+
filters.push('.e .frakignore');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
includes.forEach(include => filters.push(`+ ${include}`));
|
|
62
|
+
excludes.forEach(include => filters.push(`- ${include}`));
|
|
63
|
+
|
|
64
|
+
return filters;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Execute the entire rsync command. Uses rsync's --files-from
|
|
69
|
+
* parameter to deploy multiple paths at once.
|
|
70
|
+
*
|
|
71
|
+
* @param {String} source
|
|
72
|
+
* @param {String} destination
|
|
73
|
+
* @param {Object} options
|
|
74
|
+
*/
|
|
75
|
+
export function exec(...options) {
|
|
76
|
+
// Get the current command
|
|
77
|
+
const { command, options: commandOptions } = parse(process.argv.slice(2));
|
|
78
|
+
let source, destination;
|
|
79
|
+
|
|
80
|
+
// Flip the source and destination if this is a pull command
|
|
81
|
+
if (command === 'pull') {
|
|
82
|
+
// trailing slashes are important for pull
|
|
83
|
+
source = `${config['server']}:${config['root']}/`;
|
|
84
|
+
destination = './';
|
|
85
|
+
} else {
|
|
86
|
+
source = './';
|
|
87
|
+
destination = `${config['server']}:${config['root']}/`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let backup = [];
|
|
91
|
+
let backupName = 'name' in commandOptions
|
|
92
|
+
? `${commandOptions.name}_${(new Date()).toISOString()}`
|
|
93
|
+
: (new Date()).toISOString();
|
|
94
|
+
|
|
95
|
+
// Figure out backup options
|
|
96
|
+
backup = [
|
|
97
|
+
'--exclude=.backups/',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// Keep the same backup path for all rsync commands
|
|
101
|
+
if (backupPath === null) {
|
|
102
|
+
backupPath = `.backups/${backupName}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// This should only happen on push
|
|
106
|
+
if (command === 'push') {
|
|
107
|
+
backup = [...backup, '--backup', `--backup-dir='${backupPath}'`]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const args = [
|
|
111
|
+
'rsync',
|
|
112
|
+
[
|
|
113
|
+
'--archive', '--no-group', '--no-owner', '--no-perms', '--no-times', '--human-readable',
|
|
114
|
+
'--compress', '--checksum', '--itemize-changes', '--recursive', '--delete',
|
|
115
|
+
`--rsync-path=${config['rsync_path'] ? config['rsync_path'] : 'rsync'}`,
|
|
116
|
+
'--filter=. -',
|
|
117
|
+
...backup,
|
|
118
|
+
...options,
|
|
119
|
+
source, destination,
|
|
120
|
+
],
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
debug('rsync:', args);
|
|
124
|
+
debug(args.flat().join(' '));
|
|
125
|
+
debug('filters:', filters());
|
|
126
|
+
|
|
127
|
+
let output = '';
|
|
128
|
+
|
|
129
|
+
const rsync = spawn.apply(null, args);
|
|
130
|
+
|
|
131
|
+
const echo = spawn('echo', [ filters().join('\n') ]);
|
|
132
|
+
|
|
133
|
+
echo.stdout.pipe(rsync.stdin);
|
|
134
|
+
|
|
135
|
+
rsync.stdout.on('data', (data) => {
|
|
136
|
+
output += `${data}`;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
rsync.stderr.on('data', (data) => {
|
|
140
|
+
process.stderr.write(`${data}`); // Print any errors from the child process
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
rsync.on('exit', (code) => {
|
|
145
|
+
if (code === 0) {
|
|
146
|
+
resolve({
|
|
147
|
+
output,
|
|
148
|
+
backupPath,
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
reject(`rsync exited with code ${code}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
package/src/ssh.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import debug from './debug.js';
|
|
3
|
+
|
|
4
|
+
export function exec(command, options = {}) {
|
|
5
|
+
debug({ command });
|
|
6
|
+
|
|
7
|
+
const cmd = `cd ${options.root || '.'}; sh`;
|
|
8
|
+
|
|
9
|
+
const ssh = spawn('ssh', [ options.server, '--', cmd ]);
|
|
10
|
+
|
|
11
|
+
// const echo = spawn('echo', [ command ]);
|
|
12
|
+
|
|
13
|
+
// echo.stdout.pipe(ssh.stdin);
|
|
14
|
+
|
|
15
|
+
ssh.stdin.write(command);
|
|
16
|
+
ssh.stdin.end();
|
|
17
|
+
|
|
18
|
+
ssh.stdout.on('data', (data) => {
|
|
19
|
+
debug('' + data);
|
|
20
|
+
|
|
21
|
+
options.quiet || process.stdout.write(`${data}`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
ssh.stderr.on('data', (data) => {
|
|
25
|
+
debug('' + data);
|
|
26
|
+
|
|
27
|
+
options.quiet || process.stderr.write(`${data}`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const promise = new Promise((resolve, reject) => {
|
|
31
|
+
ssh.on('exit', (code) => {
|
|
32
|
+
if (code === 0) {
|
|
33
|
+
resolve();
|
|
34
|
+
} else {
|
|
35
|
+
reject(`exited with code ${code}`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
promise.stdout = ssh.stdout;
|
|
41
|
+
promise.stderr = ssh.stderr;
|
|
42
|
+
|
|
43
|
+
return promise;
|
|
44
|
+
}
|