@frakjs/frak 0.1.1 → 0.3.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/.prettierrc ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "tabSize": 4,
3
+ "singleQuote": true,
4
+ "semi": true,
5
+ "trailingComma": "es5",
6
+ "printWidth": 100,
7
+ "bracketSpacing": true
8
+ }
package/README.md CHANGED
@@ -6,19 +6,18 @@
6
6
  [![Stargazers][stars-shield]][stars-url]
7
7
  [![Issues][issues-shield]][issues-url]
8
8
  [![MIT][license-shield]][license-url]
9
- [![LinkedIn][linkedin-shield]][linkedin-url]
10
9
 
11
10
  <!-- PROJECT LOGO -->
12
11
  <br />
13
12
  <div align="center">
14
13
  <a href="https://github.com/frakjs/frak">
15
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-upload-icon lucide-cloud-upload"><path d="M12 13v8"/><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="m8 17 4-4 4 4"/></svg>
14
+ <img src="images/logos/Frak Logo - Green - 256.png" width="112" />
16
15
  </a>
17
16
 
18
17
  <h3 align="center">frak</h3>
19
18
 
20
19
  <p align="center">
21
- Quick deployment tool using Rsync and SSH.
20
+ Deploy from point A → point B, without the drama.
22
21
  <br />
23
22
  <br />
24
23
  <a href="https://github.com/frakjs/frak/issues/new?labels=bug&template=bug-report---.md">Report Bug</a>
@@ -38,7 +37,6 @@
38
37
  <a href="#getting-started">Getting Started</a>
39
38
  <ul>
40
39
  <li><a href="#prerequisites">Prerequisites</a></li>
41
- <li><a href="#installation">Installation</a></li>
42
40
  </ul>
43
41
  </li>
44
42
  <li><a href="#usage">Usage</a></li>
@@ -46,7 +44,6 @@
46
44
  <li><a href="#contributing">Contributing</a></li>
47
45
  <li><a href="#license">License</a></li>
48
46
  <li><a href="#contact">Contact</a></li>
49
- <li><a href="#acknowledgments">Acknowledgments</a></li>
50
47
  </ol>
51
48
  </details>
52
49
 
@@ -57,9 +54,9 @@
57
54
 
58
55
  [![Product Name Screen Shot][product-screenshot]](https://frakjs.com)
59
56
 
60
- **`frak`** is an agentless deployment tool for web apps. It leverages `rsync` over SSH to push files to a remote server, and is able to visualize file changes in a developer-friendly way. **`frak`** has a number of advanced features beyond syncing files to a remote server, such as keeping a history of deployments for easy rollback, showing file differences prior to pushing, and post-deploy hooks.
57
+ **`frak`** is a tiny JavaScript binary for deployments that don’t need containers or repos—just fast, reliable file transfer from point A to point B.
61
58
 
62
- > “Frak it, ship it.”
59
+ It leverages `rsync` over SSH to push files to a remote server, and is able to visualize file changes in a developer-friendly way. **`frak`** has a number of advanced features beyond syncing files to a remote server, such as keeping a history of deployments for easy rollback, showing file differences prior to pushing, and executing post-deploy hooks.
63
60
 
64
61
  ### 🚀 Features
65
62
 
@@ -76,7 +73,7 @@
76
73
  <!-- GETTING STARTED -->
77
74
  ## Getting Started
78
75
 
79
- Using frak is simple. All you need is a [Node.js](https://nodejs.org) environment with `npx`.
76
+ Using **`frak`** is simple. All you need is a [Node.js](https://nodejs.org) environment with `npx`.
80
77
 
81
78
  ### Prerequisites
82
79
 
@@ -95,7 +92,7 @@ npx --version
95
92
  <!-- USAGE EXAMPLES -->
96
93
  ## Usage
97
94
 
98
- The recommended way to run `frak` is via the `npx` command.
95
+ The recommended way to run **`frak`** is via the `npx` command.
99
96
 
100
97
  ```bash
101
98
  npx @frakjs/frak --help
@@ -189,7 +186,7 @@ Distributed under the MIT License. See `LICENSE.txt` for more information.
189
186
  <!-- CONTACT -->
190
187
  ## Contact
191
188
 
192
- Your Name - [@strube](https://twitter.com/strube) - contact@frakjs.com
189
+ Frank Strube - [@strube](https://x.com/strube) - contact@frakjs.com
193
190
 
194
191
  Project Link: [https://github.com/frakjs/frak](https://github.com/frakjs/frak)
195
192
 
@@ -207,8 +204,7 @@ Project Link: [https://github.com/frakjs/frak](https://github.com/frakjs/frak)
207
204
  [issues-url]: https://github.com/frakjs/frak/issues
208
205
  [license-shield]: https://img.shields.io/github/license/frakjs/frak.svg?style=for-the-badge
209
206
  [license-url]: https://github.com/frakjs/frak/blob/master/LICENSE.txt
210
- [linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
211
- [linkedin-url]: https://linkedin.com/in/linkedin_username
212
- [product-screenshot]: images/screenshot.png
213
- [prerequisites-screenshot]: images/node-npm-version.png
214
- [frak-init-screenshot]: images/frak-init-screenshot.png
207
+ [green-logo]: images/logos/Frak%20Logo%20-%20Green%20-%20256.png
208
+ [product-screenshot]: images/screenshots/screenshot.png
209
+ [prerequisites-screenshot]: images/screenshots/node-npm-version.png
210
+ [frak-init-screenshot]: images/screenshots/frak-init-screenshot.png
package/bin/frak.js CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execSync, spawn } from 'node:child_process';
4
- import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
5
- import { fileURLToPath } from 'node:url';
4
+ import { writeFileSync, existsSync } from 'node:fs';
6
5
  import path from 'node:path';
7
6
  import * as ansi from '../src/ansi.js';
8
7
  import * as cli from '../src/cli.js';
@@ -15,23 +14,17 @@ import * as rsync from '../src/rsync.js';
15
14
  import * as ssh from '../src/ssh.js';
16
15
  import * as date from '../src/date.js';
17
16
 
18
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
-
20
17
  /**
21
18
  * Try to load config. If it fails, print error message in red.
22
19
  *
23
20
  * @param {String} env
24
21
  */
25
22
  async function tryLoadConfig(env) {
26
- try {
27
- await loadConfig(env);
28
- } catch (e) {
29
- console.log(ansi.red(e.message));
23
+ return await loadConfig(env).catch((e) => {
24
+ console.error(ansi.red(e.message));
30
25
 
31
26
  process.exit(1);
32
- }
33
-
34
- return;
27
+ });
35
28
  }
36
29
 
37
30
  /**
@@ -58,21 +51,22 @@ async function main(args) {
58
51
 
59
52
  debug('parsed:', { command, options });
60
53
 
61
-
62
- // Initialize Frak in the current directory
63
54
  if (command === 'init') {
55
+ // Initialize Frak in the current directory
64
56
  const configPath = path.join(process.cwd(), 'frak.config.js');
65
57
 
66
58
  if (!existsSync(configPath)) {
67
- writeFileSync(configPath, `export default ${JSON.stringify({ server: 'example.com', root: '/var/www/html' }, null, 4)}`);
59
+ writeFileSync(
60
+ configPath,
61
+ `export default ${JSON.stringify({ server: 'example.com', root: '/var/www/html' }, null, 4)}`
62
+ );
68
63
  console.info(ansi.green('Configuration file created: frak.config.js'));
69
64
  } else {
70
65
  console.error(ansi.red('Frak is already initialized in this directory.'));
71
66
  }
72
-
73
- // Start a console session, or run the provided command on
74
- // the remote server.
75
67
  } else if (command === 'console') {
68
+ // Start a console session, or run the provided command on
69
+ // the remote server.
76
70
  await tryLoadConfig(options.env);
77
71
 
78
72
  if (!config.server) {
@@ -81,48 +75,65 @@ async function main(args) {
81
75
  }
82
76
 
83
77
  try {
84
- const cmd = options.command ?? '\\$SHELL -l -i';
78
+ const cmd = options.command || '\\$SHELL -l -i';
85
79
 
86
- execSync(`ssh -t ${config.server} -- cd ${config.root} \\&\\& ${cmd}`, { stdio: 'inherit' });
80
+ execSync(`ssh -t ${config.server} -- cd ${config.root} \\&\\& ${cmd}`, {
81
+ stdio: 'inherit',
82
+ });
87
83
  } catch (e) {
88
- debug(e)
84
+ debug(e);
89
85
  }
90
- // Show the differences between local files and remote server
91
86
  } else if (command === 'diff') {
87
+ // Show the differences between local files and remote server
92
88
  await tryLoadConfig(options.env);
93
89
 
94
- const { output } = await rsync.exec('--dry-run');
90
+ try {
91
+ const { output } = await rsync.exec('--dry-run');
92
+
93
+ debug({ output });
95
94
 
96
- await diff(output, options);
95
+ await diff(output, options);
96
+ } catch (e) {
97
+ debug(e);
97
98
 
98
- // Push or pull changes to remote server
99
+ console.error(e.message);
100
+ }
99
101
  } else if (command === 'push' || command === 'pull') {
102
+ // Push or pull changes to remote server
100
103
  await tryLoadConfig(options.env);
101
104
 
102
105
  // Execute dry-run
103
106
  const { output } = await rsync.exec('--dry-run');
104
107
 
108
+ debug({ output });
109
+
105
110
  console.log(humanize(output));
106
111
 
107
112
  if (command === 'push' && config.after) {
108
- console.log(ansi.blue('Run command:'), config.after, '\n');
113
+ console.log(ansi.blue('Run command:'), config.after);
114
+ console.log('');
109
115
  }
110
116
 
111
- const proceed = await cli.agree(ansi.yellow('The above actions will be taken. Continue? (This cannot be undone): '));
117
+ const proceed = await cli.agree(
118
+ ansi.yellow('The above actions will be taken. Continue? (This cannot be undone): ')
119
+ );
112
120
 
113
121
  if (!proceed) {
114
122
  process.exit(1);
115
123
  }
116
124
 
117
125
  // Prepare patch file
118
- let patch;
126
+ let patch = '';
119
127
 
120
- try {
121
- patch = await diff(output, { interactive: false });
122
- } catch (e) {
123
- console.error(e);
124
-
125
- process.exit(1);
128
+ if (command === 'push') {
129
+ patch = await diff(output, { interactive: false })
130
+ /* node:coverage disable */
131
+ .catch((e) => {
132
+ console.error(e);
133
+
134
+ process.exit(1);
135
+ });
136
+ /* node:coverage enable */
126
137
  }
127
138
 
128
139
  // Execute actual rsync command
@@ -132,12 +143,18 @@ async function main(args) {
132
143
  console.log(humanize(actualOutput));
133
144
 
134
145
  // 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'`]);
146
+ if (command === 'push') {
147
+ const hash = sha256(patch).slice(0, 7);
148
+ const copy = spawn('ssh', [
149
+ config.server,
150
+ '--',
151
+ `cat > '${config.root || '.'}/${backupPath}/${hash}.patch'`,
152
+ ]);
137
153
 
138
- copy.stdout.on('data', (data) => debug('' + data));
139
- copy.stderr.on('data', (data) => debug('' + data));
140
- copy.stdin.write(patch);
154
+ copy.stdout.on('data', (data) => debug('' + data));
155
+ copy.stderr.on('data', (data) => debug('' + data));
156
+ copy.stdin.write(patch);
157
+ }
141
158
 
142
159
  // Execute "after" command
143
160
  if (command === 'push' && config.after) {
@@ -145,11 +162,12 @@ async function main(args) {
145
162
 
146
163
  debug({ command: config.after });
147
164
 
148
- try {
149
- await ssh.exec(config.after, { server: config.server, root: config.root });
150
- } catch (e) {
151
- debug(e);
152
- }
165
+ await ssh
166
+ .exec(config.after, {
167
+ server: config.server,
168
+ root: config.root,
169
+ })
170
+ .catch(debug);
153
171
  }
154
172
 
155
173
  // TODO Webhook
@@ -164,13 +182,16 @@ async function main(args) {
164
182
  const grep = {
165
183
  additions: `grep -e '^+' "$NAME"/*.patch | grep -v '^+++' | wc -l`,
166
184
  deletions: `grep -e '^-' "$NAME"/*.patch | grep -v '^---' | wc -l`,
167
- }
185
+ };
168
186
  const ls = `stat --format='%Y %n' .backups/* | sort -r`;
169
187
 
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 });
188
+ const list = ssh.exec(
189
+ `${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})'`,
190
+ { quiet: true, server: config.server, root: config.root }
191
+ );
171
192
  let backups = '';
172
193
 
173
- list.stdout.on('data', data => backups += data);
194
+ list.stdout.on('data', (data) => (backups += data));
174
195
 
175
196
  await list;
176
197
 
@@ -186,17 +207,22 @@ async function main(args) {
186
207
  {
187
208
  header: 'Changes',
188
209
  width: 30,
189
- }
210
+ },
190
211
  ]);
191
212
 
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
- });
213
+ backups
214
+ .trim()
215
+ .split(/\n/)
216
+ .forEach((backup) => {
217
+ const [timestamp, name, additions, deletions] = backup.split(delimeter);
218
+ table.row([
219
+ name.replace(/^.backups\//, ''),
220
+ date.relative(timestamp * 1000),
221
+ ansi.green(`${additions} addition${additions == 1 ? '' : 's'}`) +
222
+ ', ' +
223
+ ansi.red(`${deletions} deletion${deletions == 1 ? '' : 's'}`),
224
+ ]);
225
+ });
200
226
 
201
227
  table.print();
202
228
  } else {
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "@frakjs/frak",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "A commandline deployment tool for syncing files to remote servers.",
5
5
  "main": "index.js",
6
+ "engines": {
7
+ "node": ">=12"
8
+ },
6
9
  "bin": {
7
10
  "frak": "bin/frak.js"
8
11
  },
9
12
  "type": "module",
10
13
  "scripts": {
11
- "test": "echo \"Error: no test specified\" && exit 1"
14
+ "eslint": "eslint",
15
+ "test": "node --experimental-test-coverage tests/index.test.js"
12
16
  },
13
17
  "repository": {
14
18
  "type": "git",
@@ -24,5 +28,13 @@
24
28
  "bugs": {
25
29
  "url": "https://github.com/frakjs/frak/issues"
26
30
  },
27
- "homepage": "https://github.com/frakjs/frak#readme"
31
+ "homepage": "https://github.com/frakjs/frak#readme",
32
+ "devDependencies": {
33
+ "@eslint/js": "^9.33.0",
34
+ "eslint": "^9.33.0",
35
+ "globals": "^16.3.0"
36
+ },
37
+ "dependencies": {
38
+ "yaml": "^1.10.0"
39
+ }
28
40
  }
package/src/ansi.js CHANGED
@@ -15,7 +15,7 @@ const ansi = {
15
15
  };
16
16
 
17
17
  export function strip(string) {
18
- return string
18
+ return `${string}`
19
19
  .replace(new RegExp(ansi.CLEAR.replace('[', '\\['), 'g'), '')
20
20
  .replace(new RegExp(ansi.BOLD.replace('[', '\\['), 'g'), '')
21
21
  .replace(new RegExp(ansi.UNDERLINE.replace('[', '\\['), 'g'), '')
package/src/cli.js CHANGED
@@ -13,7 +13,11 @@ export function parse(args) {
13
13
  };
14
14
 
15
15
  for (let arg of args) {
16
- if (arg.includes('=')) {
16
+ if (arg === '--yes' || arg === '-y') {
17
+ options.interactive = false;
18
+ } else if (arg === '--no-color') {
19
+ options.colorize = false;
20
+ } else if (arg.includes('=')) {
17
21
  const [option, value] = arg.split('=', 2);
18
22
 
19
23
  options[option] = value;
@@ -25,11 +29,14 @@ export function parse(args) {
25
29
  return { command, options };
26
30
  }
27
31
 
28
- export async function agree(string) {
29
- process.stdout.write(string);
32
+ export async function agree(string, { stdin = null, stdout = null } = {}) {
33
+ stdout = stdout || process.stdout;
34
+ stdin = stdin || process.stdin;
30
35
 
31
- const answer = await new Promise((resolve, reject) => {
32
- process.stdin.on('data', data => resolve(data));
36
+ stdout.write(string);
37
+
38
+ const answer = await new Promise((resolve) => {
39
+ stdin.on('data', (data) => resolve(data));
33
40
  });
34
41
 
35
42
  return ['y', 'yes'].includes(`${answer}`.trim().toLowerCase());
@@ -49,8 +56,8 @@ export class Table {
49
56
  const { border, header } = this.columns.reduce(({ header, border }, column, i) => {
50
57
  const width = ansi.strip(column.header).length;
51
58
  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('-'));
59
+ const paddingString = new Array(padding + 1).join(' ');
60
+ const borderString = new Array(column.width + 1).join('-');
54
61
 
55
62
  return {
56
63
  border: `${border || ''}+${borderString}${i === this.columns.length - 1 ? '+' : ''}`,
@@ -62,18 +69,17 @@ export class Table {
62
69
  console.log(header);
63
70
  console.log(ansi.gray(border));
64
71
 
65
- this.rows.forEach(r => {
72
+ this.rows.forEach((r) => {
66
73
  const rowString = r.reduce((row, cell, i) => {
67
- const width = ansi.strip(cell).length
74
+ const width = ansi.strip(cell).length;
68
75
  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('-'));
76
+ const paddingString = new Array(padding + 1).join(' ');
71
77
 
72
- return `${row || ''}${ansi.gray('|')} ${cell}${paddingString} ${i === r.length - 1 ? ansi.gray('|') : ''}`
78
+ return `${row || ''}${ansi.gray('|')} ${cell}${paddingString} ${i === r.length - 1 ? ansi.gray('|') : ''}`;
73
79
  }, '');
74
80
 
75
81
  console.log(rowString);
76
82
  console.log(ansi.gray(border));
77
- })
83
+ });
78
84
  }
79
85
  }
package/src/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import * as yaml from 'yaml';
3
4
 
4
5
  // Cached config object. The config is stored in here so it can
5
6
  // be loaded once and imported across all modules.
@@ -13,10 +14,15 @@ let config = {};
13
14
  export function locate() {
14
15
  for (let dir = process.cwd(); dir != '/'; dir = path.dirname(dir)) {
15
16
  const configPath = path.join(dir, 'frak.config.js');
17
+ const yamlPath = path.join(dir, '.frak');
16
18
 
17
19
  if (fs.existsSync(configPath)) {
18
20
  return configPath;
19
21
  }
22
+
23
+ if (fs.existsSync(yamlPath)) {
24
+ return yamlPath;
25
+ }
20
26
  }
21
27
 
22
28
  return null;
@@ -30,22 +36,47 @@ export function locate() {
30
36
  export async function load(env = null) {
31
37
  const configPath = locate();
32
38
 
33
- if (fs.existsSync(configPath)) {
39
+ if (!fs.existsSync(configPath)) {
40
+ throw new Error('Config file not found! Try running frak init.');
41
+ }
42
+
43
+ if (path.basename(configPath) === 'frak.config.js') {
34
44
  Object.assign(config, (await import(configPath)).default);
45
+ } else if (path.basename(configPath) === '.frak') {
46
+ Object.assign(config, yaml.parse(fs.readFileSync(configPath).toString('utf-8')));
47
+ }
35
48
 
36
- if (env in config) {
37
- return Object.assign(config, {...config[env]});
38
- }
49
+ if (env in config) {
50
+ Object.assign(config, { ...config[env] });
39
51
 
40
- if (env) {
41
- throw new Error(`Missing configuration for environment "${env}"!`);
42
- }
52
+ delete config[env];
53
+ } else if (env) {
54
+ throw new Error(`Missing configuration for environment "${env}"!`);
55
+ }
56
+
57
+ // Support legacy config, remote_path instead of root
58
+ if ('remote_path' in config) {
59
+ config.root = config.remote_path;
43
60
 
44
- return config;
61
+ delete config.remote_path;
45
62
  }
46
63
 
47
- throw new Error('Config file not found! Try running frak init.');
64
+ return config;
48
65
  }
49
66
 
50
- export default config;
67
+ /**
68
+ * Reset the config, useful when unit testing.
69
+ *
70
+ * @returns {Object}
71
+ */
72
+ export function reset() {
73
+ const old = { ...config };
51
74
 
75
+ Object.entries(config).forEach(([key]) => {
76
+ delete config[key];
77
+ });
78
+
79
+ return old;
80
+ }
81
+
82
+ export default config;
package/src/date.js CHANGED
@@ -29,32 +29,32 @@ export function relative(date) {
29
29
  value = 'just';
30
30
  unit = 'now';
31
31
  agoOrFromNow = '';
32
- // Less than a minute
33
32
  } else if (seconds < 60) {
33
+ // Less than a minute
34
34
  value = Math.floor(seconds);
35
35
  unit = plural('second', value);
36
- // Less than an hour
37
36
  } else if (seconds < 60 * 60) {
37
+ // Less than an hour
38
38
  value = Math.floor(seconds / 60);
39
39
  unit = plural('minute', value);
40
- // Less than a day
41
40
  } else if (seconds < 60 * 60 * 24) {
41
+ // Less than a day
42
42
  value = Math.floor(seconds / 60 / 60);
43
43
  unit = plural('hour', value);
44
- // Less than a week
45
44
  } else if (seconds < 60 * 60 * 24 * 7) {
45
+ // Less than a week
46
46
  value = Math.floor(seconds / 60 / 60 / 24);
47
47
  unit = plural('day', value);
48
- // Less than a month
49
48
  } else if (seconds < 60 * 60 * 24 * 30) {
49
+ // Less than a month
50
50
  value = Math.floor(seconds / 60 / 60 / 24 / 7);
51
51
  unit = plural('week', value);
52
- // Less than a year
53
52
  } else if (seconds < 60 * 60 * 24 * 365) {
53
+ // Less than a year
54
54
  value = Math.floor(seconds / 60 / 60 / 24 / 30);
55
55
  unit = plural('month', value);
56
56
  } else {
57
- return `${months[datetime.getMonth()]} ${datetime.getDay()}, ${datetime.getFullYear()} @ ${datetime.getHours() % 12}:${`0${datetime.getMinutes()}`.slice(-2)} ${datetime.getHours() >= 12 ? 'pm' : 'am'}`;
57
+ return `${months[datetime.getMonth()]} ${datetime.getDate()}, ${datetime.getFullYear()} @ ${datetime.getHours() % 12}:${`0${datetime.getMinutes()}`.slice(-2)} ${datetime.getHours() >= 12 ? 'pm' : 'am'}`;
58
58
  }
59
59
 
60
60
  return `${value} ${unit} ${agoOrFromNow}`.trim();
package/src/diff.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { statSync } from 'node:fs';
3
3
  import * as ansi from './ansi.js';
4
+ import * as cli from './cli.js';
4
5
  import config from './config.js';
5
6
  import debug from './debug.js';
6
7
 
@@ -15,17 +16,17 @@ export default async function diff(dryRunOutput, options) {
15
16
  const changes = dryRunOutput
16
17
  .split(/\r?\n/)
17
18
  .filter(Boolean)
18
- .map(line => {
19
+ .map((line) => {
19
20
  // Split line into action and file, preserving whitespace for filenames
20
21
  const [action, ...parts] = line.split(/\s+/);
21
22
 
22
23
  let file, target;
23
24
 
24
- // Links also have a target
25
- if (action === 'cL+++++++++' || action === 'cLc.T......') {
25
+ // Links also have a target, cL+++++++++ or cLc.T......
26
+ if (action.match(/^cL.+/)) {
26
27
  const index = parts.indexOf('->');
27
28
 
28
- debug({ parts })
29
+ debug({ parts });
29
30
 
30
31
  file = parts.slice(0, index).join(' ');
31
32
  target = parts.slice(index + 1).join(' ');
@@ -35,65 +36,75 @@ export default async function diff(dryRunOutput, options) {
35
36
 
36
37
  return [action, file, target];
37
38
  })
38
- .filter(([action, file]) => file.slice(-1) !== '/');
39
+ .filter(([, file]) => file.slice(-1) !== '/');
39
40
 
40
- debug({ changes })
41
+ debug({ changes });
41
42
 
42
43
  let proceed = true;
43
44
 
44
45
  if (changes.length >= 50 && options.interactive) {
45
- proceed = await cli.agree(ansi.yellow(`${changes.length} file(s) will be compared. Continue?`))
46
+ proceed = await cli.agree(
47
+ ansi.yellow(`${changes.length} file(s) will be compared. Continue? `)
48
+ );
46
49
  }
47
50
 
48
51
  if (!proceed) {
49
52
  return Promise.reject('Process canceled by user');
50
53
  }
51
54
 
52
- const files = changes.map(([action, file]) => file);
55
+ const files = changes.map(([, file]) => file);
53
56
 
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
- ];
57
+ const diffs = changes
58
+ .sort((a, b) => a[1].localeCompare(b[1]))
59
+ .map((change) => {
60
+ const [action, file] = change;
59
61
 
60
- let diff = [];
62
+ // Escape double quotes in file
63
+ const fileEscaped = file.replace(/"/g, '\\"');
61
64
 
62
- if (action === '*deleting') {
63
- header.push('deleted file mode 100644');
64
- header.push('index 0000000..0000000');
65
+ const header = [
66
+ file.match(/"/)
67
+ ? `diff --git "a/${fileEscaped}" "b/${fileEscaped}"`
68
+ : `diff --git a/${file} b/${file}`,
69
+ ];
65
70
 
66
- diff = ['diff', '-Naurp', `--label=a/${file}`, `--label=/dev/null`, file, '/dev/null'];
67
- } else if (action === '<f+++++++++') {
68
- const { mode } = statSync(file);
71
+ let diff = '';
69
72
 
70
- header.push(`new file mode ${mode.toString(8)}`);
71
- header.push('index 0000000..0000000');
73
+ if (action === '*deleting') {
74
+ header.push('deleted file mode 100644');
75
+ header.push('index 0000000..0000000');
72
76
 
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];
77
+ diff = `diff -Naurp --label="a/${fileEscaped}" --label=/dev/null "${fileEscaped}" /dev/null`;
78
+ } else if (action.match(/^<f\++/)) {
79
+ const { mode } = statSync(file);
76
80
 
77
- header.push('index 0000000..0000000');
81
+ header.push(`new file mode ${mode.toString(8)}`);
82
+ header.push('index 0000000..0000000');
78
83
 
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];
84
+ diff = `diff -Naurp --label=/dev/null --label="b/${fileEscaped}" /dev/null <(tar xzOf $tarball "${fileEscaped}" || cat /dev/null)`;
85
+ } else if (action.match(/^cL\++/)) {
86
+ const target = change[2];
82
87
 
83
- header.push('new file mode 120000');
84
- header.push('index 0000000..0000000');
88
+ header.push('new file mode 120000');
89
+ header.push('index 0000000..0000000');
85
90
 
86
- diff = ['diff', '-Naurp', `--label=/dev/null`, `--label=b/${file}`, '/dev/null', `<(printf %s ${target})`];
87
- } else {
88
- const { mode } = statSync(file);
91
+ diff = `diff -Naurp --label=/dev/null --label="b/${fileEscaped}" /dev/null <(printf %s "${target}")`;
92
+ } else if (action.match(/^cL.+/)) {
93
+ const target = change[2];
89
94
 
90
- header.push(`index 0000000..0000000 ${mode.toString(8)}`);
95
+ header.push('index 0000000..0000000');
91
96
 
92
- diff = ['diff', '-Naurp', `--label=a/${file}`, `--label=b/${file}`, file, `<(tar xzOf $tarball --warning=no-unknown-keyword ${file} || cat /dev/null)`];
93
- }
97
+ diff = `diff -Naurp --label="a/${fileEscaped}" --label="b/${fileEscaped}" <(printf %s "$(readlink "${fileEscaped}")") <(printf %s "${target}")`;
98
+ } else {
99
+ const { mode } = statSync(file);
94
100
 
95
- return [`printf "%s\\n" "${header.join('\n')}"`, ';', ...diff, ';'];
96
- });
101
+ header.push(`index 0000000..0000000 ${mode.toString(8)}`);
102
+
103
+ diff = `diff -Naurp --label="a/${fileEscaped}" --label="b/${fileEscaped}" "${fileEscaped}" <(tar xzOf $tarball "${fileEscaped}" || cat /dev/null)`;
104
+ }
105
+
106
+ return `printf "%s\\n" '${header.join('\n')}'; ${diff}`;
107
+ });
97
108
 
98
109
  if (diffs.length > 0) {
99
110
  debug({ diffs, files });
@@ -103,40 +114,53 @@ export default async function diff(dryRunOutput, options) {
103
114
  const ssh = spawn('ssh', [
104
115
  config.server,
105
116
  '--',
106
- 'tarball=$(mktemp /tmp/frak-diff-tarball.XXXXXXXX);',
117
+ 'tarball=$(mktemp /tmp/frak-diff-tarball.XXXXXX);',
107
118
  'cat > $tarball;',
108
119
  `cd ${config.root};`,
109
- ...diffs.reduce((commands, diff) => commands.concat(...diff), []),
110
- // `${diffs.join(';')};`,
111
- // 'rm $tarball',
112
- ])
120
+ diffs.join('; '),
121
+ ';',
122
+ 'rm $tarball',
123
+ ]);
113
124
  tar.stdout.pipe(ssh.stdin);
114
125
 
115
126
  let less;
116
127
 
117
128
  if (options.interactive) {
118
- less = spawn('less', ['-r'], { stdio: ['pipe', 'inherit', 'inherit'] });
129
+ less = spawn('less', ['-R'], { stdio: ['pipe', process.stdout, process.stderr] });
119
130
  }
120
131
 
121
132
  if (options.interactive && options.colorize) {
122
133
  const colorize = spawn('sed', [
123
134
  // 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')}/`,
135
+ '-e',
136
+ `s/^\\(diff --git .*\\)/${ansi.bold('\\1')}/`,
137
+ '-e',
138
+ `s/^\\(index .*\\)/${ansi.bold('\\1')}/`,
139
+ '-e',
140
+ `s/^\\(new file mode .*\\)/${ansi.bold('\\1')}/`,
141
+ '-e',
142
+ `s/^\\(deleted file mode .*\\)/${ansi.bold('\\1')}/`,
143
+ '-e',
144
+ `s/^\\(--- .*\\)/${ansi.bold('\\1')}/`,
145
+ '-e',
146
+ `s/^\\(+++ .*\\)/${ansi.bold('\\1')}/`,
130
147
  // additions
131
- '-e', `s/^\\(+[^+].*\\)/${ansi.green('\\1')}/`,
132
- '-e', `s/^\\(+\\)$/${ansi.green('\\1')}/`,
148
+ '-e',
149
+ `s/^\\(+[^+].*\\)/${ansi.green('\\1')}/`,
150
+ '-e',
151
+ `s/^\\(+\\)$/${ansi.green('\\1')}/`,
133
152
  // deletions
134
- '-e', `s/^\\(-[^-].*\\)/${ansi.red('\\1')}/`,
135
- '-e', `s/^\\(-\\)$/${ansi.red('\\1')}/`,
153
+ '-e',
154
+ `s/^\\(-[^-].*\\)/${ansi.red('\\1')}/`,
155
+ '-e',
156
+ `s/^\\(-\\)$/${ansi.red('\\1')}/`,
136
157
  // 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')}/`,
158
+ '-e',
159
+ `s/^\\(@@ -[0-9][0-9]*,[0-9][0-9]* +[0-9][0-9]*,[0-9][0-9]* .*@@\\)/${ansi.blue('\\1')}/`,
160
+ '-e',
161
+ `s/^\\(@@ -[0-9][0-9]*,[0-9][0-9]* +[0-9][0-9]* .*@@\\)/${ansi.blue('\\1')}/`,
162
+ '-e',
163
+ `s/^\\(@@ -[0-9][0-9]* +[0-9][0-9]*,[0-9][0-9]* .*@@\\)/${ansi.blue('\\1')}/`,
140
164
  ]);
141
165
  ssh.stdout.pipe(colorize.stdin);
142
166
  colorize.stdout.pipe(less.stdin);
@@ -150,24 +174,30 @@ export default async function diff(dryRunOutput, options) {
150
174
  if (code === 0) {
151
175
  resolve();
152
176
  } else {
177
+ /* node :coverage disable */
153
178
  reject(`exited with code ${code}`);
179
+ /* node:coverage enable */
154
180
  }
155
181
  });
156
182
  });
157
183
  } else {
158
- return await new Promise((resolve, reject) => {
184
+ /* node:coverage disable */
185
+ return await new Promise((resolve) => {
159
186
  let diff = '';
160
187
 
161
- ssh.stdout.on('data', (data) => diff += data);
188
+ ssh.stdout.on('data', (data) => (diff += data));
162
189
 
163
190
  ssh.stderr.pipe(process.stderr);
164
191
 
165
- ssh.on('exit', (code) => {
192
+ ssh.on('exit', () => {
166
193
  resolve(diff);
167
194
  });
168
195
  });
196
+ /* node:coverage enable */
169
197
  }
170
198
  } else if (options.interactive) {
171
199
  console.log(ansi.yellow('No changes detected.'));
172
200
  }
201
+
202
+ return '';
173
203
  }
package/src/humanize.js CHANGED
@@ -9,5 +9,5 @@ export default function humanize(changes) {
9
9
  .replace(/<fc..\.\.\.\.\.\./g, ansi.yellow('Update '))
10
10
  .replace(/>fc..\.\.\.\.\.\./g, ansi.yellow('Update '))
11
11
  .replace(/cLc\.T\.\.\.\.\.\./g, ansi.yellow('Update '))
12
- .replace(/\*deleting /g, ansi.red('Delete '))
12
+ .replace(/\*deleting {2}/g, ansi.red('Delete '))
13
13
  }
package/src/rsync.js CHANGED
@@ -19,6 +19,7 @@ export function filters() {
19
19
  '.git*',
20
20
  '.svn*',
21
21
  '.tags*',
22
+ 'frak.config.js',
22
23
  'Capfile',
23
24
  'Gemfile*',
24
25
  'Vagrantfile*',
@@ -31,22 +32,24 @@ export function filters() {
31
32
 
32
33
  // merge path
33
34
  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 }
35
+ const paths = options.path
36
+ .split(/((?<!\\) )+/)
37
+ .map((p) => p.replace(/\\ /g, ' '))
38
+ .map((p) => path.resolve(p))
39
+ .map((p) => p) // TODO glob // paths.map! { |p| p[/[*?]/] ? Dir.glob(p) : p }
37
40
  .flat(Infinity)
38
41
  // make paths relative to .frak file
39
- .map(p => p.replace(path.dirname(locateConfig()), ''));
42
+ .map((p) => p.replace(path.dirname(locateConfig()), ''));
40
43
 
41
44
  // include all parent directories to fix recursive rsync from excluding all
42
- paths.forEach(p => {
45
+ paths.forEach((p) => {
43
46
  includes.push(p);
44
47
 
45
48
  while (path.dirname(p) !== '/') {
46
49
  excludes.push(`${path.dirname(p)}/*`);
47
50
 
48
51
  p = path.dirname(p);
49
- includes.push(p)
52
+ includes.push(p);
50
53
  }
51
54
  });
52
55
 
@@ -58,8 +61,8 @@ export function filters() {
58
61
  filters.push('.e .frakignore');
59
62
  }
60
63
 
61
- includes.forEach(include => filters.push(`+ ${include}`));
62
- excludes.forEach(include => filters.push(`- ${include}`));
64
+ includes.forEach((include) => filters.push(`+ ${include}`));
65
+ excludes.forEach((include) => filters.push(`- ${include}`));
63
66
 
64
67
  return filters;
65
68
  }
@@ -88,35 +91,45 @@ export function exec(...options) {
88
91
  }
89
92
 
90
93
  let backup = [];
91
- let backupName = 'name' in commandOptions
92
- ? `${commandOptions.name}_${(new Date()).toISOString()}`
93
- : (new Date()).toISOString();
94
+ let backupName =
95
+ 'name' in commandOptions
96
+ ? `${commandOptions.name}_${new Date().toISOString()}`
97
+ : new Date().toISOString();
94
98
 
95
99
  // Figure out backup options
96
- backup = [
97
- '--exclude=.backups/',
98
- ];
100
+ backup = ['--exclude=.backups/'];
99
101
 
100
102
  // Keep the same backup path for all rsync commands
101
103
  if (backupPath === null) {
102
- backupPath = `.backups/${backupName}`
104
+ backupPath = `.backups/${backupName}`;
103
105
  }
104
106
 
105
107
  // This should only happen on push
106
108
  if (command === 'push') {
107
- backup = [...backup, '--backup', `--backup-dir='${backupPath}'`]
109
+ backup = [...backup, '--backup', `--backup-dir=${backupPath}`];
108
110
  }
109
111
 
110
112
  const args = [
111
113
  'rsync',
112
114
  [
113
- '--archive', '--no-group', '--no-owner', '--no-perms', '--no-times', '--human-readable',
114
- '--compress', '--checksum', '--itemize-changes', '--recursive', '--delete',
115
+ '--archive',
116
+ '--no-group',
117
+ '--no-owner',
118
+ '--no-perms',
119
+ '--no-times',
120
+ '--human-readable',
121
+ '--compress',
122
+ '--checksum',
123
+ '--itemize-changes',
124
+ '--recursive',
125
+ '--delete',
126
+ process.env.DEBUG ? '--rsh=ssh' : '--rsh=ssh -q',
115
127
  `--rsync-path=${config['rsync_path'] ? config['rsync_path'] : 'rsync'}`,
116
128
  '--filter=. -',
117
129
  ...backup,
118
130
  ...options,
119
- source, destination,
131
+ source,
132
+ destination,
120
133
  ],
121
134
  ];
122
135
 
@@ -124,21 +137,27 @@ export function exec(...options) {
124
137
  debug(args.flat().join(' '));
125
138
  debug('filters:', filters());
126
139
 
127
- let output = '';
140
+ let output = '',
141
+ stderr = '';
128
142
 
129
143
  const rsync = spawn.apply(null, args);
130
144
 
131
- const echo = spawn('echo', [ filters().join('\n') ]);
145
+ const echo = spawn('echo', [filters().join('\n')]);
132
146
 
133
147
  echo.stdout.pipe(rsync.stdin);
134
148
 
135
149
  rsync.stdout.on('data', (data) => {
136
150
  output += `${data}`;
137
151
  });
152
+ rsync.stderr.on('data', (data) => {
153
+ stderr += `${data}`;
154
+ });
138
155
 
156
+ /* node:coverage disable */
139
157
  rsync.stderr.on('data', (data) => {
140
158
  process.stderr.write(`${data}`); // Print any errors from the child process
141
159
  });
160
+ /* node:coverage enable */
142
161
 
143
162
  return new Promise((resolve, reject) => {
144
163
  rsync.on('exit', (code) => {
@@ -148,7 +167,9 @@ export function exec(...options) {
148
167
  backupPath,
149
168
  });
150
169
  } else {
151
- reject(`rsync exited with code ${code}`);
170
+ /* node:coverage disable */
171
+ reject(`rsync exited with code ${code}: ${stderr}`);
172
+ /* node:coverage enable */
152
173
  }
153
174
  });
154
175
  });
package/src/ssh.js CHANGED
@@ -6,7 +6,7 @@ export function exec(command, options = {}) {
6
6
 
7
7
  const cmd = `cd ${options.root || '.'}; sh`;
8
8
 
9
- const ssh = spawn('ssh', [ options.server, '--', cmd ]);
9
+ const ssh = spawn('ssh', [options.server, '--', cmd]);
10
10
 
11
11
  // const echo = spawn('echo', [ command ]);
12
12
 
@@ -32,7 +32,9 @@ export function exec(command, options = {}) {
32
32
  if (code === 0) {
33
33
  resolve();
34
34
  } else {
35
+ /* node:coverage disable */
35
36
  reject(`exited with code ${code}`);
37
+ /* node:coverage enable */
36
38
  }
37
39
  });
38
40
  });
package/.frakignore DELETED
File without changes
package/eslint.config.js DELETED
@@ -1 +0,0 @@
1
- export default [{}]
package/frak.config.js DELETED
@@ -1,10 +0,0 @@
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
- };