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 +21 -9
- package/dist/deploy.js +177 -22
- package/dist/index.js +1 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
process.
|
|
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
|
-
//
|
|
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(
|
|
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();
|