@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 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);
@@ -0,0 +1 @@
1
+ export default [{}]
package/frak.config.js ADDED
@@ -0,0 +1,10 @@
1
+ export default {
2
+ server: 'franklinstrube.com',
3
+ root: '/home/ash/frak',
4
+ after: 'touch deployed; pwd',
5
+ method: 'rsync',
6
+
7
+ production: {
8
+ root: '/home/ash/frak-production',
9
+ },
10
+ };
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
@@ -0,0 +1,5 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export function sha256(text) {
4
+ return crypto.createHash('sha256').update(text).digest('hex');
5
+ }
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
@@ -0,0 +1,10 @@
1
+ import * as ansi from '../src/ansi.js';
2
+
3
+ /**
4
+ * Wrapper for console.log, only prints in DEBUG mode.
5
+ */
6
+ export default function debug() {
7
+ if (process.env.DEBUG) {
8
+ console.log.apply(console, [ansi.bold(ansi.cyan('DEBUG:')), ...arguments]);
9
+ }
10
+ }
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
+ }
@@ -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
+ }