dxfl 0.1.3 → 0.1.5
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 +18 -9
- package/dist/auth.js +81 -0
- package/dist/deploy.js +236 -0
- package/dist/vhosts.js +20 -0
- package/package.json +1 -1
- package/auth.ts +0 -81
- package/deploy.ts +0 -152
- package/dist/auth.d.ts +0 -3
- package/dist/deploy.d.ts +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/vhosts.d.ts +0 -1
- package/index.ts +0 -27
- package/vhosts.ts +0 -9
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,24 @@ 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
|
+
npm link
|
|
40
|
+
dxfl
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Release
|
|
44
|
+
|
|
45
|
+
First you need an account on npmjs.com and be a maintainer of the `dxfl` package (ask quentin).
|
|
46
|
+
Do not forget also to run `npm login` to bind your account with the CLI.
|
|
47
|
+
|
|
48
|
+
Then to publish a release:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
vim package.json # update the version in this file
|
|
52
|
+
git commit -a -m 'set version 0.1.5' # commit your change
|
|
53
|
+
git push # send update
|
|
54
|
+
git tag -m 'v0.1.5' v0.1.5 # create associated tag
|
|
55
|
+
git push --tags # push tag
|
|
56
|
+
npm publish # build and push the package
|
|
48
57
|
```
|
|
49
58
|
|
|
50
59
|
## License
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { Configuration, WebsiteApi } from "guichet-sdk-ts";
|
|
11
|
+
import { read } from 'read';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
14
|
+
function configPath() {
|
|
15
|
+
let path = ".dfl/config.json";
|
|
16
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
17
|
+
path = process.env.XDG_CONFIG_HOME + "/dfl/config.json";
|
|
18
|
+
}
|
|
19
|
+
else if (process.env.HOME) {
|
|
20
|
+
path = process.env.HOME + "/.config/dfl/config.json";
|
|
21
|
+
}
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
export function openApiConf() {
|
|
25
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
26
|
+
let strConf;
|
|
27
|
+
let dictConf;
|
|
28
|
+
const configFile = configPath();
|
|
29
|
+
try {
|
|
30
|
+
strConf = yield fs.readFile(configFile, { encoding: 'utf8' });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.error(err, `\n\nUnable to read ${configFile}, run 'dfl login' first.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
dictConf = JSON.parse(strConf);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error(err, `\n\nUnable to parse ${configFile} as JSON, check your syntax. Did you manually edit this file?`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
// @FIXME: we do not validate that the dictConf object really contains a username or password field...
|
|
44
|
+
return new Configuration({
|
|
45
|
+
username: dictConf.username,
|
|
46
|
+
password: dictConf.password,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function login(username) {
|
|
51
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
52
|
+
const password = yield read({
|
|
53
|
+
prompt: "password: ",
|
|
54
|
+
silent: true,
|
|
55
|
+
replace: "*"
|
|
56
|
+
});
|
|
57
|
+
// check config
|
|
58
|
+
const testConf = new Configuration({
|
|
59
|
+
username: username,
|
|
60
|
+
password: password,
|
|
61
|
+
});
|
|
62
|
+
const web = new WebsiteApi(testConf);
|
|
63
|
+
try {
|
|
64
|
+
yield web.listWebsites();
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error(err, `\n\nLogin failed. Is your username and password correct?`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
// create config folder if needed
|
|
71
|
+
const configFile = configPath();
|
|
72
|
+
const parent = path.dirname(configFile);
|
|
73
|
+
yield fs.mkdir(parent, { recursive: true });
|
|
74
|
+
// create, serialize, save config data
|
|
75
|
+
const configData = { username, password };
|
|
76
|
+
const serializedConfig = JSON.stringify(configData);
|
|
77
|
+
yield fs.writeFile(configFile, serializedConfig, { mode: 0o600 });
|
|
78
|
+
// @FIXME: we would like to avoid storing the password in clear text in the future.
|
|
79
|
+
console.log('ok');
|
|
80
|
+
});
|
|
81
|
+
}
|
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
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
|
+
};
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
import path from "path";
|
|
19
|
+
import crypto from "crypto";
|
|
20
|
+
import mime from "mime";
|
|
21
|
+
import { WebsiteApi } from "guichet-sdk-ts";
|
|
22
|
+
import { S3Client, ListObjectsV2Command, DeleteObjectsCommand, HeadObjectCommand, } from "@aws-sdk/client-s3";
|
|
23
|
+
import { Upload } from "@aws-sdk/lib-storage";
|
|
24
|
+
import { PromisePool } from "@supercharge/promise-pool";
|
|
25
|
+
import { openApiConf } from "./auth.js";
|
|
26
|
+
const MD5METAFIELD = "dfl-md5sum";
|
|
27
|
+
// Walks through the local directory at path `dir`, and for each file it contains, returns :
|
|
28
|
+
// - `localPath`: its path on the local filesystem (includes `dir`). On windows, this path
|
|
29
|
+
// will typically use `\` as separator.
|
|
30
|
+
// - `s3Path`: an equivalent path as we would store it in an S3 bucket, using '/' as separator.
|
|
31
|
+
// This path includes `s3Prefix` as a prefix if provided. If `s3Prefix` is null, `s3Path`
|
|
32
|
+
// is relative to the root (of the form "a/b/c", instead of "/a/b/c" if `s3Prefix` is "").
|
|
33
|
+
function getLocalFiles(dir, s3Prefix) {
|
|
34
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
35
|
+
const entries = yield fs.promises.readdir(dir, { withFileTypes: true });
|
|
36
|
+
const files = yield Promise.all(entries.map(entry => {
|
|
37
|
+
const localPath = path.join(dir, entry.name);
|
|
38
|
+
const s3Path = s3Prefix ? s3Prefix + "/" + entry.name : entry.name;
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
return getLocalFiles(localPath, s3Path);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
return Promise.resolve([{ localPath, s3Path }]);
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
return files.flat();
|
|
47
|
+
});
|
|
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
|
+
}
|
|
71
|
+
function getBucketFiles(client, Bucket) {
|
|
72
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
73
|
+
const files = new Map();
|
|
74
|
+
let done = false;
|
|
75
|
+
let cmd = new ListObjectsV2Command({ Bucket });
|
|
76
|
+
while (!done) {
|
|
77
|
+
const resp = yield client.send(cmd);
|
|
78
|
+
if (resp.$metadata.httpStatusCode != 200) {
|
|
79
|
+
// TODO: better error handling?
|
|
80
|
+
console.error(resp);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
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
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (resp.NextContinuationToken) {
|
|
91
|
+
cmd = new ListObjectsV2Command({
|
|
92
|
+
Bucket,
|
|
93
|
+
ContinuationToken: resp.NextContinuationToken
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
done = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return files;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function uploadFile(client, Bucket, Key, Body, md5) {
|
|
104
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
105
|
+
var _a;
|
|
106
|
+
// use `path.posix` because `Key` is a path in a bucket that uses `/` as separator.
|
|
107
|
+
let ContentType = (_a = mime.getType(path.posix.extname(Key))) !== null && _a !== void 0 ? _a : undefined;
|
|
108
|
+
// add charset=utf-8 by default on text files (TODO: allow the user to override this)
|
|
109
|
+
if (ContentType && ContentType.startsWith("text/")) {
|
|
110
|
+
ContentType = ContentType + "; charset=utf-8";
|
|
111
|
+
}
|
|
112
|
+
// store the md5 checksum in the object metadata; it will be used to skip
|
|
113
|
+
// subsequent uploads if the file has not changed.
|
|
114
|
+
const Metadata = { [MD5METAFIELD]: md5 };
|
|
115
|
+
const params = { Bucket, Key, Body, ContentType, Metadata };
|
|
116
|
+
const parallelUpload = new Upload({ client, params });
|
|
117
|
+
parallelUpload.on("httpUploadProgress", progress => {
|
|
118
|
+
process.stdout.moveCursor(0, -1);
|
|
119
|
+
process.stdout.clearLine(1);
|
|
120
|
+
process.stdout.write(`Sending ${progress.Key}`);
|
|
121
|
+
if (!(progress.loaded == progress.total && progress.part == 1)) {
|
|
122
|
+
process.stdout.write(` (${progress.loaded}/${progress.total})`);
|
|
123
|
+
}
|
|
124
|
+
process.stdout.write("\n");
|
|
125
|
+
});
|
|
126
|
+
yield parallelUpload.done();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function deleteFiles(client, Bucket, files) {
|
|
130
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
131
|
+
if (files.length == 0) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return yield client.send(new DeleteObjectsCommand({
|
|
135
|
+
Bucket,
|
|
136
|
+
Delete: {
|
|
137
|
+
Objects: files.map(f => { return { Key: f }; }),
|
|
138
|
+
},
|
|
139
|
+
}));
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Checks whether a remote file needs to be updated by its local copy.
|
|
143
|
+
//
|
|
144
|
+
// We first check whether files differ, and if not compare the md5 checksum we
|
|
145
|
+
// previously stored in the object metadata (if it exists) with the local file's
|
|
146
|
+
// md5 checksum.
|
|
147
|
+
function needsUpdate(client, localFile, localMd5, Bucket, Key, remoteSize) {
|
|
148
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
149
|
+
if (remoteSize) {
|
|
150
|
+
const localSize = (yield fs.promises.stat(localFile)).size;
|
|
151
|
+
if (localSize == 0 /* stat can return 0 in case of error */
|
|
152
|
+
|| localSize != remoteSize) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// fetch metadata for the object and see if we previously stored its md5
|
|
157
|
+
const resp = yield client.send(new HeadObjectCommand({ Bucket, Key }));
|
|
158
|
+
if (resp.$metadata.httpStatusCode != 200) {
|
|
159
|
+
// TODO: better error handling?
|
|
160
|
+
throw resp;
|
|
161
|
+
}
|
|
162
|
+
const remoteMd5 = resp.Metadata ? resp.Metadata[MD5METAFIELD] : null;
|
|
163
|
+
if (!remoteMd5) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
// we have a remote md5, compare it with the local one
|
|
167
|
+
return (localMd5 != remoteMd5);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
export function deploy(vhost, localFolder) {
|
|
171
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
172
|
+
const conf = yield openApiConf();
|
|
173
|
+
// Get paths of the local files to deploy
|
|
174
|
+
const localFiles = yield getLocalFiles(localFolder, "").catch(err => {
|
|
175
|
+
if (err.errno = -2) {
|
|
176
|
+
console.error(`Error: directory '${localFolder}' does not exist`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.error(err);
|
|
180
|
+
}
|
|
181
|
+
process.exit(1);
|
|
182
|
+
});
|
|
183
|
+
// Get website info from guichet (bucket name and keys)
|
|
184
|
+
const api = new WebsiteApi(conf);
|
|
185
|
+
let vhostInfo = yield api.getWebsite({ vhost }).catch(err => {
|
|
186
|
+
if (err.response.status == 404) {
|
|
187
|
+
console.error(`Error: website '${vhost}' does not exist`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
console.error(err);
|
|
191
|
+
}
|
|
192
|
+
process.exit(1);
|
|
193
|
+
});
|
|
194
|
+
// List the files currently stored in the bucket
|
|
195
|
+
// @FIXME this info could be returned by the guichet API
|
|
196
|
+
const s3client = new S3Client({
|
|
197
|
+
endpoint: "https://garage.deuxfleurs.fr",
|
|
198
|
+
region: "garage",
|
|
199
|
+
forcePathStyle: true,
|
|
200
|
+
credentials: {
|
|
201
|
+
accessKeyId: vhostInfo.accessKeyId,
|
|
202
|
+
secretAccessKey: vhostInfo.secretAccessKey,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
const Bucket = vhostInfo.vhost.name;
|
|
206
|
+
const remoteFiles = yield getBucketFiles(s3client, Bucket);
|
|
207
|
+
// Delete files that are present in the bucket but not locally.
|
|
208
|
+
// Do this before sending the new files to avoid hitting the size quota
|
|
209
|
+
// unnecessarily.
|
|
210
|
+
const resp = yield deleteFiles(s3client, Bucket, [...remoteFiles]
|
|
211
|
+
.filter(([name, _]) => !localFiles.find(({ s3Path }) => s3Path == name))
|
|
212
|
+
.map(([name, _]) => name));
|
|
213
|
+
if (resp && resp.$metadata.httpStatusCode != 200) {
|
|
214
|
+
// TODO: better error handling?
|
|
215
|
+
console.error(resp);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
// Uploads a local file unless the remote copy is the same
|
|
219
|
+
function processFile(localPath, s3Path) {
|
|
220
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
221
|
+
const localMd5 = yield getFileMd5(localPath);
|
|
222
|
+
const remoteFile = remoteFiles.get(s3Path);
|
|
223
|
+
if (!remoteFile ||
|
|
224
|
+
(yield needsUpdate(s3client, localPath, localMd5, Bucket, s3Path, remoteFile.size))) {
|
|
225
|
+
uploadFile(s3client, Bucket, s3Path, fs.createReadStream(localPath), localMd5);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
;
|
|
230
|
+
// Control concurrency while uploading
|
|
231
|
+
yield PromisePool
|
|
232
|
+
.for(localFiles)
|
|
233
|
+
.withConcurrency(6)
|
|
234
|
+
.process(({ localPath, s3Path }) => processFile(localPath, s3Path));
|
|
235
|
+
});
|
|
236
|
+
}
|
package/dist/vhosts.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { WebsiteApi } from "guichet-sdk-ts";
|
|
11
|
+
import { openApiConf } from "./auth.js";
|
|
12
|
+
export function vhostsList() {
|
|
13
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
14
|
+
var _a;
|
|
15
|
+
const conf = yield openApiConf();
|
|
16
|
+
const web = new WebsiteApi(conf);
|
|
17
|
+
const wlist = yield web.listWebsites();
|
|
18
|
+
(_a = wlist.vhosts) === null || _a === void 0 ? void 0 : _a.forEach(v => console.log(v.name));
|
|
19
|
+
});
|
|
20
|
+
}
|
package/package.json
CHANGED
package/auth.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { Configuration, WebsiteApi } from "guichet-sdk-ts";
|
|
2
|
-
import { read } from 'read';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import fs from 'node:fs/promises';
|
|
5
|
-
|
|
6
|
-
function configPath(): string {
|
|
7
|
-
let path = ".dfl/config.json";
|
|
8
|
-
if (process.env.XDG_CONFIG_HOME) {
|
|
9
|
-
path = process.env.XDG_CONFIG_HOME + "/dfl/config.json";
|
|
10
|
-
} else if (process.env.HOME) {
|
|
11
|
-
path = process.env.HOME + "/.config/dfl/config.json";
|
|
12
|
-
}
|
|
13
|
-
return path
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface PersistedConfig {
|
|
17
|
-
username: string,
|
|
18
|
-
password: string,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function openApiConf() {
|
|
22
|
-
let strConf: string;
|
|
23
|
-
let dictConf: PersistedConfig;
|
|
24
|
-
|
|
25
|
-
const configFile = configPath();
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
strConf = await fs.readFile(configFile, { encoding: 'utf8' });
|
|
29
|
-
} catch (err) {
|
|
30
|
-
console.error(err, `\n\nUnable to read ${configFile}, run 'dfl login' first.`);
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
dictConf = JSON.parse(strConf);
|
|
36
|
-
} catch (err) {
|
|
37
|
-
console.error(err, `\n\nUnable to parse ${configFile} as JSON, check your syntax. Did you manually edit this file?`);
|
|
38
|
-
process.exit(1);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// @FIXME: we do not validate that the dictConf object really contains a username or password field...
|
|
42
|
-
|
|
43
|
-
return new Configuration({
|
|
44
|
-
username: dictConf.username,
|
|
45
|
-
password: dictConf.password,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function login(username: string) {
|
|
50
|
-
const password = await read({
|
|
51
|
-
prompt: "password: ",
|
|
52
|
-
silent: true,
|
|
53
|
-
replace: "*"
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// check config
|
|
57
|
-
const testConf = new Configuration({
|
|
58
|
-
username: username,
|
|
59
|
-
password: password,
|
|
60
|
-
});
|
|
61
|
-
const web = new WebsiteApi(testConf);
|
|
62
|
-
try {
|
|
63
|
-
await web.listWebsites();
|
|
64
|
-
} catch (err) {
|
|
65
|
-
console.error(err, `\n\nLogin failed. Is your username and password correct?`);
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// create config folder if needed
|
|
70
|
-
const configFile = configPath();
|
|
71
|
-
const parent = path.dirname(configFile);
|
|
72
|
-
await fs.mkdir(parent, { recursive: true });
|
|
73
|
-
|
|
74
|
-
// create, serialize, save config data
|
|
75
|
-
const configData: PersistedConfig = { username, password };
|
|
76
|
-
const serializedConfig = JSON.stringify(configData);
|
|
77
|
-
await fs.writeFile(configFile, serializedConfig, { mode: 0o600 });
|
|
78
|
-
// @FIXME: we would like to avoid storing the password in clear text in the future.
|
|
79
|
-
|
|
80
|
-
console.log('ok')
|
|
81
|
-
}
|
package/deploy.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import mime from "mime";
|
|
4
|
-
import { WebsiteApi } from "guichet-sdk-ts";
|
|
5
|
-
import {
|
|
6
|
-
S3Client,
|
|
7
|
-
ListObjectsV2Command,
|
|
8
|
-
DeleteObjectsCommand,
|
|
9
|
-
DeleteObjectsCommandOutput,
|
|
10
|
-
} from "@aws-sdk/client-s3";
|
|
11
|
-
import { Upload } from "@aws-sdk/lib-storage";
|
|
12
|
-
import { PromisePool } from "@supercharge/promise-pool";
|
|
13
|
-
import { openApiConf } from "./auth.js";
|
|
14
|
-
|
|
15
|
-
// Walks through the local directory at path `dir`, and for each file it contains, returns :
|
|
16
|
-
// - `localPath`: its path on the local filesystem (includes `dir`). On windows, this path
|
|
17
|
-
// will typically use `\` as separator.
|
|
18
|
-
// - `s3Path`: an equivalent path as we would store it in an S3 bucket, using '/' as separator.
|
|
19
|
-
// This path includes `s3Prefix` as a prefix if provided. If `s3Prefix` is null, `s3Path`
|
|
20
|
-
// is relative to the root (of the form "a/b/c", instead of "/a/b/c" if `s3Prefix` is "").
|
|
21
|
-
async function getLocalFiles(dir: string, s3Prefix: string | null): Promise<{ localPath: string, s3Path: string}[]> {
|
|
22
|
-
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
23
|
-
const files = await Promise.all(entries.map(entry => {
|
|
24
|
-
const localPath = path.join(dir, entry.name);
|
|
25
|
-
const s3Path = s3Prefix ? s3Prefix + "/" + entry.name : entry.name;
|
|
26
|
-
if (entry.isDirectory()) {
|
|
27
|
-
return getLocalFiles(localPath, s3Path)
|
|
28
|
-
} else {
|
|
29
|
-
return Promise.resolve([{ localPath, s3Path }])
|
|
30
|
-
}
|
|
31
|
-
}));
|
|
32
|
-
return files.flat()
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function getBucketFiles(client: S3Client, Bucket: string): Promise<string[]> {
|
|
36
|
-
const files = [];
|
|
37
|
-
let done = false;
|
|
38
|
-
let cmd = new ListObjectsV2Command({ Bucket });
|
|
39
|
-
while (!done) {
|
|
40
|
-
const resp = await client.send(cmd);
|
|
41
|
-
if (resp.$metadata.httpStatusCode != 200) {
|
|
42
|
-
// TODO: better error handling?
|
|
43
|
-
console.error(resp);
|
|
44
|
-
process.exit(1)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
for (var item of resp.Contents!) {
|
|
48
|
-
files.push(item.Key!)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (resp.NextContinuationToken) {
|
|
52
|
-
cmd = new ListObjectsV2Command({
|
|
53
|
-
Bucket,
|
|
54
|
-
ContinuationToken: resp.NextContinuationToken
|
|
55
|
-
})
|
|
56
|
-
} else {
|
|
57
|
-
done = true
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return files
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function uploadFile(client: S3Client, Bucket: string, Key: string, Body: any) {
|
|
64
|
-
// use `path.posix` because `Key` is a path in a bucket that uses `/` as separator.
|
|
65
|
-
let ContentType = mime.getType(path.posix.extname(Key)) ?? undefined;
|
|
66
|
-
// add charset=utf-8 by default on text files (TODO: allow the user to override this)
|
|
67
|
-
if (ContentType && ContentType.startsWith("text/")) {
|
|
68
|
-
ContentType = ContentType + "; charset=utf-8";
|
|
69
|
-
}
|
|
70
|
-
const parallelUpload = new Upload({ client, params: { Bucket, Key, Body, ContentType } });
|
|
71
|
-
parallelUpload.on("httpUploadProgress", progress => {
|
|
72
|
-
process.stdout.moveCursor(0, -1)
|
|
73
|
-
process.stdout.clearLine(1)
|
|
74
|
-
process.stdout.write("Sending " + progress.Key);
|
|
75
|
-
if (! (progress.loaded == progress.total && progress.part == 1)) {
|
|
76
|
-
process.stdout.write(" (" + progress.loaded + "/" + progress.total + ")");
|
|
77
|
-
}
|
|
78
|
-
process.stdout.write("\n");
|
|
79
|
-
});
|
|
80
|
-
await parallelUpload.done();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function deleteFiles(client: S3Client, Bucket: string, files: string[]): Promise<DeleteObjectsCommandOutput | null> {
|
|
84
|
-
if (files.length == 0) {
|
|
85
|
-
return null
|
|
86
|
-
}
|
|
87
|
-
return await client.send(new DeleteObjectsCommand({
|
|
88
|
-
Bucket,
|
|
89
|
-
Delete: {
|
|
90
|
-
Objects: files.map(f => { return { Key: f }}),
|
|
91
|
-
},
|
|
92
|
-
}));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export async function deploy(vhost: string, localFolder: string) {
|
|
96
|
-
const conf = await openApiConf();
|
|
97
|
-
|
|
98
|
-
// Get paths of the local files to deploy
|
|
99
|
-
const localFiles = await getLocalFiles(localFolder, "").catch(err => {
|
|
100
|
-
if (err.errno = -2) {
|
|
101
|
-
console.error(`Error: directory '${localFolder}' does not exist`);
|
|
102
|
-
} else {
|
|
103
|
-
console.error(err);
|
|
104
|
-
}
|
|
105
|
-
process.exit(1)
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// Get website info from guichet (bucket name and keys)
|
|
109
|
-
const api = new WebsiteApi(conf);
|
|
110
|
-
let vhostInfo = await api.getWebsite({ vhost }).catch(err => {
|
|
111
|
-
if (err.response.status == 404) {
|
|
112
|
-
console.error(`Error: website '${vhost}' does not exist`);
|
|
113
|
-
} else {
|
|
114
|
-
console.error(err);
|
|
115
|
-
}
|
|
116
|
-
process.exit(1)
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// List the files currently stored in the bucket
|
|
120
|
-
// @FIXME this info could be returned by the guichet API
|
|
121
|
-
const s3client = new S3Client({
|
|
122
|
-
endpoint: "https://garage.deuxfleurs.fr",
|
|
123
|
-
region: "garage",
|
|
124
|
-
forcePathStyle: true,
|
|
125
|
-
credentials: {
|
|
126
|
-
accessKeyId: vhostInfo.accessKeyId!,
|
|
127
|
-
secretAccessKey: vhostInfo.secretAccessKey!,
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
|
-
const Bucket = vhostInfo.vhost!.name!;
|
|
131
|
-
const remoteFiles = await getBucketFiles(s3client, Bucket);
|
|
132
|
-
|
|
133
|
-
// Delete files that are present in the bucket but not locally.
|
|
134
|
-
// Do this before sending the new files to avoid hitting the size quota
|
|
135
|
-
// unnecessarily.
|
|
136
|
-
const resp = await deleteFiles(
|
|
137
|
-
s3client,
|
|
138
|
-
Bucket,
|
|
139
|
-
remoteFiles.filter(f => !localFiles.find(({ s3Path }) => s3Path == f))
|
|
140
|
-
);
|
|
141
|
-
if (resp && resp!.$metadata.httpStatusCode != 200) {
|
|
142
|
-
// TODO: better error handling?
|
|
143
|
-
console.error(resp);
|
|
144
|
-
process.exit(1)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Control concurrence while uploading
|
|
148
|
-
await PromisePool
|
|
149
|
-
.for(localFiles)
|
|
150
|
-
.withConcurrency(6)
|
|
151
|
-
.process(({ localPath, s3Path }) => uploadFile(s3client, Bucket, s3Path,fs.createReadStream(localPath)));
|
|
152
|
-
}
|
package/dist/auth.d.ts
DELETED
package/dist/deploy.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function deploy(vhost: string, localFolder: string): Promise<void>;
|
package/dist/index.d.ts
DELETED
package/dist/vhosts.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function vhostsList(): Promise<void>;
|
package/index.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { program } from "commander";
|
|
3
|
-
import { login } from "./auth.js";
|
|
4
|
-
import { deploy } from "./deploy.js";
|
|
5
|
-
import { vhostsList } from "./vhosts.js";
|
|
6
|
-
|
|
7
|
-
program
|
|
8
|
-
.name('dxfl')
|
|
9
|
-
.description('Deuxfleurs CLI tool')
|
|
10
|
-
.version('0.1.0');
|
|
11
|
-
|
|
12
|
-
program.command('login')
|
|
13
|
-
.description('Link your Deuxfleurs account with this tool.')
|
|
14
|
-
.argument('<username>', 'your account username')
|
|
15
|
-
.action(login);
|
|
16
|
-
|
|
17
|
-
program.command('list')
|
|
18
|
-
.description('List all your websites')
|
|
19
|
-
.action(vhostsList);
|
|
20
|
-
|
|
21
|
-
program.command('deploy')
|
|
22
|
-
.description('Deploy your website')
|
|
23
|
-
.argument('<vhost>', 'selected vhost')
|
|
24
|
-
.argument('<local_folder>', 'your local folder')
|
|
25
|
-
.action(deploy)
|
|
26
|
-
|
|
27
|
-
program.parse();
|
package/vhosts.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { Configuration, WebsiteApi } from "guichet-sdk-ts";
|
|
2
|
-
import { openApiConf } from "./auth.js";
|
|
3
|
-
|
|
4
|
-
export async function vhostsList() {
|
|
5
|
-
const conf = await openApiConf();
|
|
6
|
-
const web = new WebsiteApi(conf);
|
|
7
|
-
const wlist = await web.listWebsites();
|
|
8
|
-
wlist.vhosts?.forEach(v => console.log(v.name));
|
|
9
|
-
}
|