dxfl 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -4,18 +4,10 @@ A CLI tool to manage your Deuxfleurs account.
4
4
 
5
5
  ## Install
6
6
 
7
- From NPM:
8
-
9
7
  ```
10
8
  npm install -g dxfl
11
9
  ```
12
10
 
13
- From git:
14
-
15
- ```
16
- npm install -g git+https://git.deuxfleurs.fr/Deuxfleurs/dxfl
17
- ```
18
-
19
11
  ## Usage
20
12
 
21
13
  *Not ready*
@@ -44,7 +36,27 @@ dxfl deploy example.com _public
44
36
  git clone https://git.deuxfleurs.fr/Deuxfleurs/dxfl
45
37
  cd dxfl
46
38
  npm install
47
- node bootstrap.js
39
+ # build and install the tool as the `dxfl` command
40
+ npm link
41
+ dxfl
42
+ # alternatively, run the tool from the sources directly
43
+ npx dxfl
44
+ ```
45
+
46
+ ## Release
47
+
48
+ First you need an account on npmjs.com and be a maintainer of the `dxfl` package (ask quentin).
49
+ Do not forget also to run `npm login` to bind your account with the CLI.
50
+
51
+ Then to publish a release:
52
+
53
+ ```bash
54
+ vim package.json # update the version in this file
55
+ git commit -a -m 'set version 0.1.5' # commit your change
56
+ git push # send update
57
+ git tag -m 'v0.1.5' v0.1.5 # create associated tag
58
+ git push --tags # push tag
59
+ npm publish # build and push the package
48
60
  ```
49
61
 
50
62
  ## License
package/dist/deploy.js CHANGED
@@ -7,14 +7,23 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
+ var __asyncValues = (this && this.__asyncValues) || function (o) {
11
+ if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
12
+ var m = o[Symbol.asyncIterator], i;
13
+ return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
14
+ function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
15
+ function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
16
+ };
10
17
  import fs from "fs";
11
18
  import path from "path";
19
+ import crypto from "crypto";
12
20
  import mime from "mime";
13
21
  import { WebsiteApi } from "guichet-sdk-ts";
14
- import { S3Client, ListObjectsV2Command, DeleteObjectsCommand, } from "@aws-sdk/client-s3";
22
+ import { S3Client, ListObjectsV2Command, DeleteObjectsCommand, HeadObjectCommand, } from "@aws-sdk/client-s3";
15
23
  import { Upload } from "@aws-sdk/lib-storage";
16
24
  import { PromisePool } from "@supercharge/promise-pool";
17
25
  import { openApiConf } from "./auth.js";
26
+ const MD5METAFIELD = "dfl-md5sum";
18
27
  // Walks through the local directory at path `dir`, and for each file it contains, returns :
19
28
  // - `localPath`: its path on the local filesystem (includes `dir`). On windows, this path
20
29
  // will typically use `\` as separator.
@@ -37,9 +46,31 @@ function getLocalFiles(dir, s3Prefix) {
37
46
  return files.flat();
38
47
  });
39
48
  }
49
+ function getFileMd5(file) {
50
+ return __awaiter(this, void 0, void 0, function* () {
51
+ var _a, e_1, _b, _c;
52
+ const hash = crypto.createHash('md5');
53
+ try {
54
+ for (var _d = true, _e = __asyncValues(fs.createReadStream(file)), _f; _f = yield _e.next(), _a = _f.done, !_a; _d = true) {
55
+ _c = _f.value;
56
+ _d = false;
57
+ const chunk = _c;
58
+ hash.update(chunk);
59
+ }
60
+ }
61
+ catch (e_1_1) { e_1 = { error: e_1_1 }; }
62
+ finally {
63
+ try {
64
+ if (!_d && !_a && (_b = _e.return)) yield _b.call(_e);
65
+ }
66
+ finally { if (e_1) throw e_1.error; }
67
+ }
68
+ return hash.digest('hex');
69
+ });
70
+ }
40
71
  function getBucketFiles(client, Bucket) {
41
72
  return __awaiter(this, void 0, void 0, function* () {
42
- const files = [];
73
+ const files = new Map();
43
74
  let done = false;
44
75
  let cmd = new ListObjectsV2Command({ Bucket });
45
76
  while (!done) {
@@ -49,8 +80,12 @@ function getBucketFiles(client, Bucket) {
49
80
  console.error(resp);
50
81
  process.exit(1);
51
82
  }
52
- for (var item of resp.Contents) {
53
- files.push(item.Key);
83
+ if (resp.Contents) {
84
+ for (const item of resp.Contents) {
85
+ if (item.Key) {
86
+ files.set(item.Key, { size: item.Size });
87
+ }
88
+ }
54
89
  }
55
90
  if (resp.NextContinuationToken) {
56
91
  cmd = new ListObjectsV2Command({
@@ -65,7 +100,22 @@ function getBucketFiles(client, Bucket) {
65
100
  return files;
66
101
  });
67
102
  }
68
- function uploadFile(client, Bucket, Key, Body) {
103
+ function formatBytes(bytes) {
104
+ if (bytes < 1000) {
105
+ return `${bytes}B`;
106
+ }
107
+ bytes /= 1000;
108
+ if (bytes < 1000) {
109
+ return `${bytes.toFixed(2)}KB`;
110
+ }
111
+ bytes /= 1000;
112
+ if (bytes < 1000) {
113
+ return `${bytes.toFixed(2)}MB`;
114
+ }
115
+ bytes /= 1000;
116
+ return `${bytes.toFixed(2)}GB`;
117
+ }
118
+ function uploadFile(client, Bucket, Key, Body, md5, display) {
69
119
  return __awaiter(this, void 0, void 0, function* () {
70
120
  var _a;
71
121
  // use `path.posix` because `Key` is a path in a bucket that uses `/` as separator.
@@ -74,15 +124,35 @@ function uploadFile(client, Bucket, Key, Body) {
74
124
  if (ContentType && ContentType.startsWith("text/")) {
75
125
  ContentType = ContentType + "; charset=utf-8";
76
126
  }
77
- const parallelUpload = new Upload({ client, params: { Bucket, Key, Body, ContentType } });
127
+ // store the md5 checksum in the object metadata; it will be used to skip
128
+ // subsequent uploads if the file has not changed.
129
+ const Metadata = { [MD5METAFIELD]: md5 };
130
+ const params = { Bucket, Key, Body, ContentType, Metadata };
131
+ const parallelUpload = new Upload({ client, params });
78
132
  parallelUpload.on("httpUploadProgress", progress => {
79
- process.stdout.moveCursor(0, -1);
80
- process.stdout.clearLine(1);
81
- process.stdout.write("Sending " + progress.Key);
82
- if (!(progress.loaded == progress.total && progress.part == 1)) {
83
- process.stdout.write(" (" + progress.loaded + "/" + progress.total + ")");
133
+ function writeLine() {
134
+ process.stdout.write(`Sending ${progress.Key}`);
135
+ if (progress.loaded && progress.total &&
136
+ !(progress.loaded == progress.total && progress.part == 1)) {
137
+ process.stdout.write(` (${formatBytes(progress.loaded)}/${formatBytes(progress.total)})`);
138
+ }
139
+ process.stdout.write("\n");
140
+ }
141
+ ;
142
+ if (progress.Key) {
143
+ if (display.lines.has(progress.Key)) {
144
+ const offset = display.nblines - display.lines.get(progress.Key);
145
+ process.stdout.moveCursor(0, -offset);
146
+ process.stdout.clearLine(1);
147
+ writeLine();
148
+ process.stdout.moveCursor(0, offset - 1);
149
+ }
150
+ else {
151
+ display.lines.set(progress.Key, display.nblines);
152
+ display.nblines++;
153
+ writeLine();
154
+ }
84
155
  }
85
- process.stdout.write("\n");
86
156
  });
87
157
  yield parallelUpload.done();
88
158
  });
@@ -95,16 +165,44 @@ function deleteFiles(client, Bucket, files) {
95
165
  return yield client.send(new DeleteObjectsCommand({
96
166
  Bucket,
97
167
  Delete: {
98
- Objects: files.map(f => { return { Key: f }; }),
168
+ Objects: files.map(f => { return { Key: f.name }; }),
99
169
  },
100
170
  }));
101
171
  });
102
172
  }
103
- export function deploy(vhost, localFolder) {
173
+ // Checks whether a remote file needs to be updated by its local copy.
174
+ //
175
+ // We first check whether files differ, and if not compare the md5 checksum we
176
+ // previously stored in the object metadata (if it exists) with the local file's
177
+ // md5 checksum.
178
+ function needsUpdate(client, localFile, localMd5, Bucket, Key, remoteSize) {
179
+ return __awaiter(this, void 0, void 0, function* () {
180
+ if (remoteSize) {
181
+ const localSize = (yield fs.promises.stat(localFile)).size;
182
+ if (localSize == 0 /* stat can return 0 in case of error */
183
+ || localSize != remoteSize) {
184
+ return true;
185
+ }
186
+ }
187
+ // fetch metadata for the object and see if we previously stored its md5
188
+ const resp = yield client.send(new HeadObjectCommand({ Bucket, Key }));
189
+ if (resp.$metadata.httpStatusCode != 200) {
190
+ // TODO: better error handling?
191
+ throw resp;
192
+ }
193
+ const remoteMd5 = resp.Metadata ? resp.Metadata[MD5METAFIELD] : null;
194
+ if (!remoteMd5) {
195
+ return true;
196
+ }
197
+ // we have a remote md5, compare it with the local one
198
+ return (localMd5 != remoteMd5);
199
+ });
200
+ }
201
+ export function deploy(vhost, localFolder, options) {
104
202
  return __awaiter(this, void 0, void 0, function* () {
105
203
  const conf = yield openApiConf();
106
204
  // Get paths of the local files to deploy
107
- const localFiles = yield getLocalFiles(localFolder, "").catch(err => {
205
+ const localPaths = yield getLocalFiles(localFolder, "").catch(err => {
108
206
  if (err.errno = -2) {
109
207
  console.error(`Error: directory '${localFolder}' does not exist`);
110
208
  }
@@ -113,6 +211,10 @@ export function deploy(vhost, localFolder) {
113
211
  }
114
212
  process.exit(1);
115
213
  });
214
+ // Compute local file sizes
215
+ const localFiles = yield Promise.all(localPaths.map((_a) => __awaiter(this, [_a], void 0, function* ({ localPath, s3Path }) {
216
+ return { localPath, s3Path, size: (yield fs.promises.stat(localPath)).size };
217
+ })));
116
218
  // Get website info from guichet (bucket name and keys)
117
219
  const api = new WebsiteApi(conf);
118
220
  let vhostInfo = yield api.getWebsite({ vhost }).catch(err => {
@@ -140,16 +242,69 @@ export function deploy(vhost, localFolder) {
140
242
  // Delete files that are present in the bucket but not locally.
141
243
  // Do this before sending the new files to avoid hitting the size quota
142
244
  // unnecessarily.
143
- const resp = yield deleteFiles(s3client, Bucket, remoteFiles.filter(f => !localFiles.find(({ s3Path }) => s3Path == f)));
144
- if (resp && resp.$metadata.httpStatusCode != 200) {
145
- // TODO: better error handling?
146
- console.error(resp);
147
- process.exit(1);
245
+ const filesToDelete = [...remoteFiles]
246
+ .filter(([name, _]) => !localFiles.find(({ s3Path }) => s3Path == name))
247
+ .map(([name, { size }]) => ({ name, size }));
248
+ for (const file of filesToDelete) {
249
+ process.stdout.write(`Deleting ${file.name}\n`);
148
250
  }
149
- // Control concurrence while uploading
251
+ // If not in dry-run mode, send the delete command
252
+ if (!options.dryRun) {
253
+ const resp = yield deleteFiles(s3client, Bucket, filesToDelete);
254
+ if (resp && resp.$metadata.httpStatusCode != 200) {
255
+ // TODO: better error handling?
256
+ console.error(resp);
257
+ process.exit(1);
258
+ }
259
+ }
260
+ // Upload files.
261
+ let displaystate = {
262
+ lines: new Map(),
263
+ nblines: 0
264
+ };
265
+ let filesSent = [];
266
+ function processFile(f) {
267
+ return __awaiter(this, void 0, void 0, function* () {
268
+ const remoteFile = remoteFiles.get(f.s3Path);
269
+ const localMd5 = yield getFileMd5(f.localPath);
270
+ if (!remoteFile ||
271
+ (yield needsUpdate(s3client, f.localPath, localMd5, Bucket, f.s3Path, remoteFile.size))) {
272
+ filesSent.push({ s3Path: f.s3Path, size: f.size });
273
+ if (options.dryRun) {
274
+ process.stdout.write(`Sending ${f.s3Path}\n`);
275
+ }
276
+ else {
277
+ yield uploadFile(s3client, Bucket, f.s3Path, fs.createReadStream(f.localPath), localMd5, displaystate);
278
+ }
279
+ }
280
+ });
281
+ }
282
+ ;
283
+ // Control concurrency while uploading
150
284
  yield PromisePool
151
285
  .for(localFiles)
152
286
  .withConcurrency(6)
153
- .process(({ localPath, s3Path }) => uploadFile(s3client, Bucket, s3Path, fs.createReadStream(localPath)));
287
+ .process(processFile);
288
+ // Display a summary
289
+ function sum(a) { return a.reduce(((x, y) => x + y), 0); }
290
+ function formatFiles(n) {
291
+ if (n == 1) {
292
+ return `${n} file `;
293
+ }
294
+ else {
295
+ return `${n} files`;
296
+ }
297
+ }
298
+ const sizeSent = sum(filesSent.map((f) => f.size));
299
+ const sizeDeleted = sum(filesToDelete.map((f) => { var _a; return (_a = f.size) !== null && _a !== void 0 ? _a : 0; }));
300
+ let filesUnchanged = new Map(localFiles.map((f) => [f.s3Path, f.size]));
301
+ for (const f of filesSent) {
302
+ filesUnchanged.delete(f.s3Path);
303
+ }
304
+ const sizeUnchanged = sum([...filesUnchanged.values()]);
305
+ process.stdout.write("\nSummary:\n");
306
+ process.stdout.write(`${formatFiles(filesSent.length)} uploaded (${formatBytes(sizeSent)})\n`);
307
+ process.stdout.write(`${formatFiles(filesToDelete.length)} deleted (${formatBytes(sizeDeleted)})\n`);
308
+ process.stdout.write(`${formatFiles(filesUnchanged.size)} unchanged (${formatBytes(sizeUnchanged)})\n`);
154
309
  });
155
310
  }
package/dist/index.js CHANGED
@@ -18,5 +18,6 @@ program.command('deploy')
18
18
  .description('Deploy your website')
19
19
  .argument('<vhost>', 'selected vhost')
20
20
  .argument('<local_folder>', 'your local folder')
21
+ .option('-n, --dry-run', 'do a trial run without making actual changes')
21
22
  .action(deploy);
22
23
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dxfl",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Deuxfleurs Team <coucou@deuxfleurs.fr>",