dxfl 0.1.10 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ # v0.2.0
2
+
3
+ - hint towards new website names (XXX.web.deuxfleurs.fr), following an overhaul in website name handling in deuxfleurs
4
+ - thorough error handling overhaul: dxfl now handles errors gracefully in most cases
5
+
6
+ # v0.1.11
7
+
8
+ - Lower files upload concurrency (from 50 to 20) to avoid IO issues
9
+
1
10
  # v0.1.10
2
11
 
3
12
  - new `logout` command: removes dxfl config folder containing the credentials
package/README.md CHANGED
@@ -10,8 +10,6 @@ npm install -g dxfl
10
10
 
11
11
  ## Usage
12
12
 
13
- _Warning: software still in an experimental state_
14
-
15
13
  Start by logging in with your username, for example for `john`:
16
14
 
17
15
  ```
package/dist/auth.js CHANGED
@@ -7,10 +7,12 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { Configuration, WebsiteApi } from "guichet-sdk-ts";
10
+ import { Configuration } from "guichet-sdk-ts";
11
11
  import { read } from "read";
12
12
  import path from "node:path";
13
13
  import fs from "node:fs/promises";
14
+ import { ErrorMsg } from "./error.js";
15
+ import { GuichetApi } from "./guichet.js";
14
16
  import { confirmationPrompt } from "./utils.js";
15
17
  function configPath() {
16
18
  let path = ".dxfl/config.json";
@@ -80,17 +82,23 @@ export function openApiConf() {
80
82
  yield moveOldConfig(); // Check for old config folder to rename
81
83
  strConf = yield fs.readFile(configFile, { encoding: "utf8" });
82
84
  }
83
- catch (err) {
84
- console.error(err, `\n\nUnable to read ${configFile}, run 'dxfl login' first.`);
85
- process.exit(1);
85
+ catch (e) {
86
+ let msg = `failed to read ${configFile}`;
87
+ if (e instanceof Error && "code" in e && e.code) {
88
+ msg = msg + ` (${e.code})`;
89
+ }
90
+ throw new ErrorMsg(msg);
86
91
  }
87
92
  }
88
93
  try {
89
94
  dictConf = JSON.parse(strConf);
90
95
  }
91
- catch (err) {
92
- console.error(err, `\n\nUnable to parse ${configFile} as JSON, check your syntax. Did you manually edit this file?`);
93
- process.exit(1);
96
+ catch (e) {
97
+ let msg = `Unable to parse ${configFile} as JSON. Did you manually edit this file?`;
98
+ if (e instanceof Error) {
99
+ msg = msg + ` (${e.message})`;
100
+ }
101
+ throw new ErrorMsg(msg);
94
102
  }
95
103
  // @FIXME: we do not validate that the dictConf object really contains a username or password field...
96
104
  return new Configuration({
@@ -111,14 +119,8 @@ export function login(username) {
111
119
  username: username,
112
120
  password: password,
113
121
  });
114
- const web = new WebsiteApi(testConf);
115
- try {
116
- yield web.listWebsites();
117
- }
118
- catch (err) {
119
- console.error(err, `\n\nLogin failed. Is your username and password correct?`);
120
- process.exit(1);
121
- }
122
+ const guichet = new GuichetApi(testConf);
123
+ yield guichet.listWebsites();
122
124
  // create config folder if needed
123
125
  const configFile = configPath();
124
126
  const parent = path.dirname(configFile);
@@ -128,7 +130,7 @@ export function login(username) {
128
130
  const serializedConfig = JSON.stringify(configData);
129
131
  yield fs.writeFile(configFile, serializedConfig, { mode: 0o600 });
130
132
  // @FIXME: we would like to avoid storing the password in clear text in the future.
131
- console.log("ok");
133
+ console.log("Success.");
132
134
  });
133
135
  }
134
136
  export function logout(options) {
@@ -140,12 +142,11 @@ export function logout(options) {
140
142
  yield fs.access(configFile);
141
143
  }
142
144
  catch (err) {
143
- console.error(err, `\n\nUnable to find ${configFile}. Are you logged on this device?`);
144
- process.exit(1);
145
+ throw new ErrorMsg(`Unable to find ${configFile}. Are you logged on this device?`);
145
146
  }
146
147
  // If not --yes: ask for confirmation before proceeding
147
148
  if (!options.yes) {
148
- process.stdout.write(`Are you sure to clean dxfl's config folder? (${parentFolder})\n`);
149
+ process.stdout.write(`This will clear dxfl's config folder. Proceed? (${parentFolder})\n`);
149
150
  const ok = yield confirmationPrompt(() => {
150
151
  process.stdout.write("Details of planned operations:\n");
151
152
  process.stdout.write(` Delete file ${configFile}\n`);
@@ -162,8 +163,7 @@ export function logout(options) {
162
163
  process.stdout.write("You can now go outside, feel the sun and touch grass, you are free! ☀️\n");
163
164
  }
164
165
  catch (err) {
165
- console.error(err, `\n\nUnable to cleanup config folder. Do you have the permission to delete ${parentFolder}?`);
166
- process.exit(1);
166
+ throw new ErrorMsg(`Unable to cleanup config folder. Do you have the permission to delete ${parentFolder}?`);
167
167
  }
168
168
  });
169
169
  }
package/dist/bucket.js CHANGED
@@ -9,21 +9,22 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import fs from "fs";
11
11
  import mime from "mime";
12
- import { WebsiteApi } from "guichet-sdk-ts";
13
12
  import { DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
14
13
  import { PromisePool } from "@supercharge/promise-pool";
15
14
  import { apiConfExists, openApiConf } from "./auth.js";
15
+ import { ErrorMsg, wrapS3Call } from "./error.js";
16
+ import { GuichetApi } from "./guichet.js";
16
17
  import { parseEtag, toChunks, formatBytesHuman } from "./utils.js";
17
18
  export function getBucketCredentials(name) {
18
19
  return __awaiter(this, void 0, void 0, function* () {
19
20
  if (yield apiConfExists()) {
20
- const api = new WebsiteApi(yield openApiConf());
21
- return yield credentialsFromApi(api, name);
21
+ const guichet = new GuichetApi(yield openApiConf());
22
+ return yield credentialsFromApi(guichet, name);
22
23
  }
23
24
  else {
24
25
  const creds = credentialsFromEnv();
25
26
  if (creds === undefined) {
26
- throw new Error("Failed to load credentials.\n" +
27
+ throw new ErrorMsg("Failed to load credentials.\n" +
27
28
  "You need to run 'dxfl login', " +
28
29
  "or define the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.");
29
30
  }
@@ -42,19 +43,10 @@ function credentialsFromEnv() {
42
43
  return undefined;
43
44
  }
44
45
  }
45
- function credentialsFromApi(api, website) {
46
+ function credentialsFromApi(guichet, website) {
46
47
  return __awaiter(this, void 0, void 0, function* () {
47
48
  // Get website info from guichet (bucket name and keys)
48
- const bucket = yield api.getWebsite({ vhost: website }).catch(err => {
49
- var _a;
50
- if (((_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.status) == 404) {
51
- console.error(`Error: website '${website}' does not exist`);
52
- }
53
- else {
54
- console.error(err);
55
- }
56
- throw new Error(err);
57
- });
49
+ const bucket = yield guichet.getWebsite(website);
58
50
  return { key: bucket.accessKeyId, secret: bucket.secretAccessKey };
59
51
  });
60
52
  }
@@ -79,11 +71,7 @@ export function getBucketFiles(bucket) {
79
71
  let done = false;
80
72
  let cmd = new ListObjectsV2Command({ Bucket: bucket.name });
81
73
  while (!done) {
82
- const resp = yield bucket.client.send(cmd);
83
- if (resp.$metadata.httpStatusCode != 200) {
84
- // TODO: better error handling?
85
- throw resp;
86
- }
74
+ const resp = yield wrapS3Call("list bucket files", [200], () => bucket.client.send(cmd));
87
75
  if (resp.Contents) {
88
76
  for (const item of resp.Contents) {
89
77
  const etag = item.ETag ? parseEtag(item.ETag) : undefined;
@@ -110,11 +98,7 @@ export function getBucketFilesDetails(bucket, files) {
110
98
  let res = new Map();
111
99
  function doFile(file) {
112
100
  return __awaiter(this, void 0, void 0, function* () {
113
- const resp = yield bucket.client.send(new HeadObjectCommand({ Bucket: bucket.name, Key: file }));
114
- if (resp.$metadata.httpStatusCode != 200) {
115
- // TODO: better error handling?
116
- throw resp;
117
- }
101
+ const resp = yield wrapS3Call(`read metadata of file "${file}"`, [200], () => bucket.client.send(new HeadObjectCommand({ Bucket: bucket.name, Key: file })));
118
102
  res.set(file, {
119
103
  redirect: resp.WebsiteRedirectLocation,
120
104
  });
@@ -131,14 +115,10 @@ export function getBucketFilesDetails(bucket, files) {
131
115
  }
132
116
  export function deleteBucketFile(bucket, name) {
133
117
  return __awaiter(this, void 0, void 0, function* () {
134
- const resp = yield bucket.client.send(new DeleteObjectCommand({
118
+ yield wrapS3Call(`delete file "${name}"`, [204], () => bucket.client.send(new DeleteObjectCommand({
135
119
  Bucket: bucket.name,
136
120
  Key: name,
137
- }));
138
- if (resp && resp.$metadata.httpStatusCode != 204) {
139
- // TODO: better error handling?
140
- throw resp;
141
- }
121
+ })));
142
122
  });
143
123
  }
144
124
  export function deleteBucketFiles(bucket, files) {
@@ -153,18 +133,14 @@ export function deleteBucketFiles(bucket, files) {
153
133
  for (const file of files) {
154
134
  process.stdout.write(` Delete ${file.name} (${file.size ? formatBytesHuman(file.size) : "?B"})\n`);
155
135
  }
156
- const resp = yield bucket.client.send(new DeleteObjectsCommand({
136
+ yield wrapS3Call(`deleting ${files.length} files`, [200], () => bucket.client.send(new DeleteObjectsCommand({
157
137
  Bucket: bucket.name,
158
138
  Delete: {
159
139
  Objects: files.map(f => {
160
140
  return { Key: f.name };
161
141
  }),
162
142
  },
163
- }));
164
- if (resp && resp.$metadata.httpStatusCode != 200) {
165
- // TODO: better error handling?
166
- throw resp;
167
- }
143
+ })));
168
144
  }));
169
145
  });
170
146
  }
@@ -179,10 +155,7 @@ export function uploadFile(bucket, s3Path, localPath) {
179
155
  }
180
156
  const Body = fs.createReadStream(localPath);
181
157
  const params = { Bucket: bucket.name, Key: s3Path, Body, ContentType };
182
- const resp = yield bucket.client.send(new PutObjectCommand(params));
183
- if (resp && resp.$metadata.httpStatusCode != 200) {
184
- throw resp;
185
- }
158
+ yield wrapS3Call(`upload "${s3Path}"`, [200], () => bucket.client.send(new PutObjectCommand(params)));
186
159
  });
187
160
  }
188
161
  // the 'source' object may or may not exist already in the bucket.
@@ -198,9 +171,6 @@ export function putEmptyObjectRedirect(bucket, source, target) {
198
171
  Body: "source of object redirect",
199
172
  WebsiteRedirectLocation: target,
200
173
  };
201
- const resp = yield bucket.client.send(new PutObjectCommand(params));
202
- if (resp && resp.$metadata.httpStatusCode != 200) {
203
- throw resp;
204
- }
174
+ yield wrapS3Call(`create redirection source object ${source}`, [200], () => bucket.client.send(new PutObjectCommand(params)));
205
175
  });
206
176
  }
package/dist/deploy.js CHANGED
@@ -11,6 +11,7 @@ import fs from "fs";
11
11
  import path from "path";
12
12
  import { PromisePool } from "@supercharge/promise-pool";
13
13
  import { deleteBucketFile, deleteBucketFiles, getBucketCredentials, getBucket, getBucketFiles, putEmptyObjectRedirect, uploadFile, } from "./bucket.js";
14
+ import { ErrorMsg } from "./error.js";
14
15
  import { confirmationPrompt, filterMap, formatBytesHuman, formatCount, getFileMd5, sum, } from "./utils.js";
15
16
  import { equalBucketRedirect, equalCorsRules, getBucketConfig, putBucketWebsiteConfig, putCorsRules, readConfigFile, } from "./website_config.js";
16
17
  // Walks through the local directory at path `dir`, and for each file it contains, returns :
@@ -39,10 +40,10 @@ function getLocalFilesWithInfo(localFolder) {
39
40
  return __awaiter(this, void 0, void 0, function* () {
40
41
  const localPaths = yield getLocalFiles(localFolder).catch(err => {
41
42
  if (err.errno == -2) {
42
- throw `directory '${localFolder}' does not exist`;
43
+ throw new ErrorMsg(`directory '${localFolder}' does not exist`);
43
44
  }
44
45
  else {
45
- throw err;
46
+ throw new ErrorMsg(`failed to read directory ${localFolder} (${err.message})`);
46
47
  }
47
48
  });
48
49
  return yield Promise.all(localPaths.map((_a) => __awaiter(this, [_a], void 0, function* ({ localPath, s3Path }) {
@@ -73,14 +74,15 @@ function computeDeployPlan(localFiles, remoteFiles, localCfg, remoteCfg) {
73
74
  return ret;
74
75
  });
75
76
  if (localRedirectSources.length > 0) {
76
- console.error("Error: the following local files are also the source of a redirection:");
77
+ let msg = "the following local files are also the source of a redirection:\n";
77
78
  for (const [f, target] of localRedirectSources) {
78
79
  console.error(` ${f.localPath} (redirect to: ${target})`);
79
80
  }
80
- console.error("\nIt does not make sense for a local file to also be the source of a redirection,\n" +
81
- "because its contents will never be read.");
82
- console.error("Please delete these files or the corresponding redirections.");
83
- process.exit(1);
81
+ msg +=
82
+ "It does not make sense for a local file to also be the source of a redirection,\n" +
83
+ "because its contents will never be read.\n" +
84
+ "Please delete these files or the corresponding redirections.";
85
+ throw new ErrorMsg(msg);
84
86
  }
85
87
  // Compute object redirects that need to be set because the redirection is new,
86
88
  // the target was changed, or because the source object changed.
@@ -165,8 +167,8 @@ function printPlan(plan, details) {
165
167
  const proto = r.protocol ? `${r.protocol}://` : "";
166
168
  const hostname = r.hostname ? `${r.hostname}/` : "";
167
169
  const status = r.status ? ` status=${r.status}` : "";
168
- const force = r.force ? ` force=${r.force}` : "";
169
- return `${proto}${hostname}${to}${status}${force}`;
170
+ const force = r.force ? ` force=${r.force}` : "";
171
+ return `${proto}${hostname}${to} ${status}${force}`;
170
172
  }
171
173
  function showBucketRedirect(r) {
172
174
  return `${r.prefix}* -> ${showBucketRedirectTarget(r)}`;
@@ -300,7 +302,7 @@ function applyDeployPlan(bucket, plan) {
300
302
  }));
301
303
  // Upload files
302
304
  yield PromisePool.for(plan.filesToUpload)
303
- .withConcurrency(50)
305
+ .withConcurrency(20)
304
306
  .handleError(err => {
305
307
  throw err;
306
308
  })
@@ -316,17 +318,15 @@ function applyDeployPlan(bucket, plan) {
316
318
  }
317
319
  });
318
320
  }
319
- function deployMain(website, localFolder, options) {
321
+ export function deploy(website, localFolder, options) {
320
322
  return __awaiter(this, void 0, void 0, function* () {
321
323
  if (options.dryRun && options.yes) {
322
- throw "options --yes and --dry-run cannot be passed at the same time";
324
+ throw new ErrorMsg("options --yes and --dry-run cannot be passed at the same time");
323
325
  }
324
326
  // TODO: make this configurable
325
327
  const website_config_path = "deuxfleurs.toml";
326
328
  // Read and validate the local configuration file before doing anything else
327
- const localWebsiteConfig = yield readConfigFile(website_config_path).catch(err => {
328
- throw `while reading ${website_config_path}:\n${err}`;
329
- });
329
+ const localWebsiteConfig = yield readConfigFile(website_config_path);
330
330
  process.stdout.write("Fetching the website configuration and metadata...\n");
331
331
  const [localFiles, [bucket, remoteFiles, remoteWebsiteConfig]] = yield Promise.all([
332
332
  // Get paths & size of the local files to deploy
@@ -373,19 +373,3 @@ function deployMain(website, localFolder, options) {
373
373
  }
374
374
  });
375
375
  }
376
- export function deploy(website, localFolder, options) {
377
- return __awaiter(this, void 0, void 0, function* () {
378
- try {
379
- yield deployMain(website, localFolder, options);
380
- }
381
- catch (err) {
382
- if (typeof err == "string" || (err === null || err === void 0 ? void 0 : err.name) === "AbortError") {
383
- console.error(`Error: ${err}`);
384
- }
385
- else {
386
- console.error(err);
387
- }
388
- process.exit(1);
389
- }
390
- });
391
- }
package/dist/empty.js CHANGED
@@ -8,11 +8,12 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  });
9
9
  };
10
10
  import { getBucket, getBucketCredentials, getBucketFiles, deleteBucketFiles, } from "./bucket.js";
11
+ import { ErrorMsg } from "./error.js";
11
12
  import { confirmationPrompt, formatBytesHuman, formatCount, sum, } from "./utils.js";
12
- function emptyMain(website, options) {
13
+ export function empty(website, options) {
13
14
  return __awaiter(this, void 0, void 0, function* () {
14
15
  if (options.dryRun && options.yes) {
15
- console.error("Error: options --yes and --dry-run cannot be passed at the same time");
16
+ throw new ErrorMsg("options --yes and --dry-run cannot be passed at the same time");
16
17
  }
17
18
  process.stdout.write("Fetching the website configuration and metadata...\n");
18
19
  const bucket = yield getBucket(website, yield getBucketCredentials(website));
@@ -50,20 +51,3 @@ function emptyMain(website, options) {
50
51
  yield deleteBucketFiles(bucket, filesToDelete);
51
52
  });
52
53
  }
53
- export function empty(website, options) {
54
- return __awaiter(this, void 0, void 0, function* () {
55
- try {
56
- yield emptyMain(website, options);
57
- }
58
- catch (err) {
59
- if (typeof err == "string" || (err === null || err === void 0 ? void 0 : err.name) === "AbortError") {
60
- console.error(`Error: ${err}`);
61
- }
62
- else {
63
- console.error("Error:");
64
- console.error(err);
65
- }
66
- process.exit(1);
67
- }
68
- });
69
- }
package/dist/error.js ADDED
@@ -0,0 +1,113 @@
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 { S3ServiceException } from "@aws-sdk/client-s3";
11
+ export class ErrorCtx {
12
+ constructor(ctx, error) {
13
+ this.ctx = ctx;
14
+ this.error = error;
15
+ }
16
+ }
17
+ // Wraps any error returned by the function with some context.
18
+ export function withErrorCtx(ctx, f) {
19
+ return __awaiter(this, void 0, void 0, function* () {
20
+ try {
21
+ return yield f();
22
+ }
23
+ catch (e) {
24
+ throw new ErrorCtx(ctx, e);
25
+ }
26
+ });
27
+ }
28
+ export class ErrorMsg {
29
+ constructor(msg) {
30
+ this.msg = msg;
31
+ }
32
+ }
33
+ export function withErrorMsg(f, mapMsg) {
34
+ try {
35
+ return f();
36
+ }
37
+ catch (e) {
38
+ if (e instanceof ErrorMsg) {
39
+ throw new ErrorMsg(mapMsg(e.msg));
40
+ }
41
+ else {
42
+ throw e;
43
+ }
44
+ }
45
+ }
46
+ export class ErrorGuichet {
47
+ constructor(cause) {
48
+ this.cause = cause;
49
+ }
50
+ }
51
+ // Wraps a call to the S3 API, turning exceptions and unexpected error codes
52
+ // into a formatted error message
53
+ export function wrapS3Call(context, expectedCodes, f) {
54
+ return __awaiter(this, void 0, void 0, function* () {
55
+ const ctx = `when sending a remote command to ${context}:\n `;
56
+ try {
57
+ const resp = yield f();
58
+ if (!resp) {
59
+ throw new ErrorMsg(`${ctx}Empty response`);
60
+ }
61
+ if (!resp.$metadata.httpStatusCode) {
62
+ throw new ErrorMsg(`${ctx}Empty response code`);
63
+ }
64
+ if (!expectedCodes.includes(resp.$metadata.httpStatusCode)) {
65
+ throw new ErrorMsg(`${ctx}Unexpected response code: ${resp.$metadata.httpStatusCode}` +
66
+ ` (expected: ${expectedCodes.join(", ")})`);
67
+ }
68
+ return resp;
69
+ }
70
+ catch (e) {
71
+ if (e instanceof S3ServiceException) {
72
+ throw new ErrorMsg(`${ctx}${e.message}`);
73
+ }
74
+ else {
75
+ throw e;
76
+ }
77
+ }
78
+ });
79
+ }
80
+ // Must only be used in the toplevel function that implements a command.
81
+ // The entire program gets terminated if `e` is one of the errors above,
82
+ // otherwise it is thrown as an exception.
83
+ export function handleError(e) {
84
+ if (e instanceof ErrorMsg) {
85
+ console.error(`Error: ${e.msg}`);
86
+ }
87
+ else if (e instanceof ErrorGuichet) {
88
+ let msg = "Error: ";
89
+ if (e.cause.kind == "Unauthorized") {
90
+ msg +=
91
+ "failed to authenticate with Guichet. Is your password and login correct?";
92
+ }
93
+ else if (e.cause.kind == "WebsiteNotFound") {
94
+ msg += `website ${e.cause.website} does not exist`;
95
+ }
96
+ console.error(msg);
97
+ }
98
+ else {
99
+ console.error("Unexpected error:\n", e);
100
+ }
101
+ process.exit(1);
102
+ }
103
+ // Wraps a function and calls `handleError` should it return an exception.
104
+ export function withHandleErrors(f) {
105
+ return __awaiter(this, void 0, void 0, function* () {
106
+ try {
107
+ return yield f();
108
+ }
109
+ catch (e) {
110
+ return handleError(e);
111
+ }
112
+ });
113
+ }
@@ -0,0 +1,64 @@
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 { ErrorGuichet } from "./error.js";
12
+ // The GuichetApi class wraps WebsiteApi (from the guichet sdk). It catches
13
+ // errors that can happen in normal use, and it turn them into a dedicated error type
14
+ // (ErrorGuichet) that we know how to display nicely.
15
+ export class GuichetApi {
16
+ constructor(cfg) {
17
+ this.api = new WebsiteApi(cfg);
18
+ }
19
+ // NOTE: using `err instanceof ResponseError` will not work here
20
+ // because ResponseError extends Error and the following issue:
21
+ // https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work
22
+ listWebsites() {
23
+ return __awaiter(this, void 0, void 0, function* () {
24
+ try {
25
+ return yield this.api.listWebsites();
26
+ }
27
+ catch (err) {
28
+ if ((err === null || err === void 0 ? void 0 : err.name) == "ResponseError" && err.response.status == 401) {
29
+ throw new ErrorGuichet({ kind: "Unauthorized" });
30
+ }
31
+ else {
32
+ throw err;
33
+ }
34
+ }
35
+ });
36
+ }
37
+ getWebsite(vhost) {
38
+ return __awaiter(this, void 0, void 0, function* () {
39
+ try {
40
+ const websiteInfo = yield this.api.getWebsite({ vhost });
41
+ if (websiteInfo.vhost == `${vhost}.web.deuxfleurs.fr`) {
42
+ // This means that `vhost` is a website name from before
43
+ // the migration of websites *.web.deuxfleurs.fr to bucket
44
+ // names with the full domain name.
45
+ // We just warn that this is a working but deprecated name.
46
+ console.log(`Warning: the name "${vhost}" is now deprecated for this website,` +
47
+ ` you should use "${vhost}.web.deuxfleurs.fr" instead.`);
48
+ }
49
+ return websiteInfo;
50
+ }
51
+ catch (err) {
52
+ if ((err === null || err === void 0 ? void 0 : err.name) == "ResponseError" && err.response.status == 401) {
53
+ throw new ErrorGuichet({ kind: "Unauthorized" });
54
+ }
55
+ else if ((err === null || err === void 0 ? void 0 : err.name) == "ResponseError" && err.response.status == 404) {
56
+ throw new ErrorGuichet({ kind: "WebsiteNotFound", website: vhost });
57
+ }
58
+ else {
59
+ throw err;
60
+ }
61
+ }
62
+ });
63
+ }
64
+ }
package/dist/index.js CHANGED
@@ -1,19 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { program } from "@commander-js/extra-typings";
3
+ import { withHandleErrors } from "./error.js";
3
4
  import { login, logout } from "./auth.js";
4
5
  import { deploy } from "./deploy.js";
5
6
  import { empty } from "./empty.js";
6
7
  import { vhostsList } from "./vhosts.js";
7
- program.name("dxfl").description("Deuxfleurs CLI tool").version("0.1.10");
8
+ program.name("dxfl").description("Deuxfleurs CLI tool").version("0.2.0");
8
9
  program
9
10
  .command("login")
10
11
  .description("Link your Deuxfleurs account with this tool.")
11
12
  .argument("<username>", "your account username")
12
- .action(login);
13
+ .action(username => withHandleErrors(() => login(username)));
13
14
  program
14
15
  .command("list")
15
16
  .description("List all your websites")
16
- .action(vhostsList);
17
+ .action(() => withHandleErrors(vhostsList));
17
18
  program
18
19
  .command("deploy")
19
20
  .description("Deploy your website")
@@ -21,17 +22,17 @@ program
21
22
  .argument("<local_folder>", "your local folder")
22
23
  .option("-n, --dry-run", "do a trial run without making actual changes")
23
24
  .option("-y, --yes", "apply the changes without asking for confirmation")
24
- .action(deploy);
25
+ .action((website, localFolder, options) => withHandleErrors(() => deploy(website, localFolder, options)));
25
26
  program
26
27
  .command("empty")
27
28
  .description("Empty your website from its contents")
28
29
  .argument("<website>", "selected website")
29
30
  .option("-n, --dry-run", "do a trial run without making actual changes")
30
31
  .option("-y, --yes", "apply the changes without asking for confirmation")
31
- .action(empty);
32
+ .action((website, options) => withHandleErrors(() => empty(website, options)));
32
33
  program
33
34
  .command("logout")
34
35
  .description("Cleanup dxfl config containing your credentials")
35
36
  .option("-y, --yes", "clean without asking for confirmation")
36
- .action(logout);
37
+ .action(options => withHandleErrors(() => logout(options)));
37
38
  program.parse();
package/dist/utils.js CHANGED
@@ -18,6 +18,7 @@ import fs from "fs";
18
18
  import crypto from "crypto";
19
19
  import { stdin, stdout } from "process";
20
20
  import readline from "readline/promises";
21
+ import { ErrorMsg } from "./error.js";
21
22
  export function getFileMd5(file) {
22
23
  return __awaiter(this, void 0, void 0, function* () {
23
24
  var _a, e_1, _b, _c;
@@ -85,24 +86,34 @@ export function parseEtag(s) {
85
86
  }
86
87
  export function confirmationPrompt(details) {
87
88
  return __awaiter(this, void 0, void 0, function* () {
88
- const rl = readline.createInterface({ input: stdin, output: stdout });
89
- let ok = true;
90
- while (true) {
91
- const a = yield rl.question(`Proceed? y (yes), n (no), d (details): `);
92
- if (a == "y" || a == "yes") {
93
- break;
89
+ try {
90
+ const rl = readline.createInterface({ input: stdin, output: stdout });
91
+ let ok = true;
92
+ while (true) {
93
+ const a = yield rl.question(`Proceed? y (yes), n (no), d (details): `);
94
+ if (a == "y" || a == "yes") {
95
+ break;
96
+ }
97
+ else if (a == "n" || a == "no") {
98
+ ok = false;
99
+ break;
100
+ }
101
+ else if (a == "d" || a == "details") {
102
+ details();
103
+ process.stdout.write("\n");
104
+ }
94
105
  }
95
- else if (a == "n" || a == "no") {
96
- ok = false;
97
- break;
106
+ rl.close();
107
+ return ok;
108
+ }
109
+ catch (e) {
110
+ if (e.code == "ABORT_ERR") {
111
+ throw new ErrorMsg("Aborted with Ctrl+C");
98
112
  }
99
- else if (a == "d" || a == "details") {
100
- details();
101
- process.stdout.write("\n");
113
+ else {
114
+ throw e;
102
115
  }
103
116
  }
104
- rl.close();
105
- return ok;
106
117
  });
107
118
  }
108
119
  export function filterMap(a, f) {
@@ -13,6 +13,7 @@ import URI from "fast-uri";
13
13
  import { z as zod } from "zod";
14
14
  import { fromError as zodError } from "zod-validation-error";
15
15
  import { GetBucketCorsCommand, GetBucketWebsiteCommand, PutBucketWebsiteCommand, PutBucketCorsCommand, } from "@aws-sdk/client-s3";
16
+ import { ErrorMsg, withErrorMsg, wrapS3Call } from "./error.js";
16
17
  import { getBucketFilesDetails } from "./bucket.js";
17
18
  ////////////// Utilities
18
19
  export function equalBucketRedirect(b1, b2) {
@@ -61,16 +62,22 @@ function readConfigFileObject(filename) {
61
62
  try {
62
63
  strConf = yield fs.promises.readFile(filename, { encoding: "utf8" });
63
64
  }
64
- catch (err) {
65
- console.error(err, `\n\nUnable to read ${filename}`);
66
- process.exit(1);
65
+ catch (e) {
66
+ let msg = `failed to read ${filename}`;
67
+ if (e instanceof Error && "code" in e && e.code) {
68
+ msg += ` (${e.code})`;
69
+ }
70
+ throw new ErrorMsg(msg);
67
71
  }
68
72
  try {
69
73
  dictConf = TOML.parse(strConf);
70
74
  }
71
- catch (err) {
72
- console.error(err, `\n\nUnable to parse ${filename} as TOML, please check it for syntax errors.`);
73
- process.exit(1);
75
+ catch (e) {
76
+ let msg = `Unable to parse ${filename} as TOML`;
77
+ if (e instanceof Error) {
78
+ msg += `:\n${e.message}`;
79
+ }
80
+ return new ErrorMsg(msg);
74
81
  }
75
82
  return dictConf;
76
83
  });
@@ -100,7 +107,7 @@ function interpRawConfig(cfg) {
100
107
  }
101
108
  catch (err) {
102
109
  const validationError = zodError(err);
103
- throw validationError.toString();
110
+ throw new ErrorMsg(validationError.toString());
104
111
  }
105
112
  }
106
113
  // Parsing: RawConfig -> WebsiteConfig
@@ -127,7 +134,7 @@ function unescape(s) {
127
134
  out += "*";
128
135
  }
129
136
  else {
130
- throw `a single '*' is only allowed at the end for wildcards; please use '**' instead`;
137
+ throw new ErrorMsg(`a single '*' is only allowed at the end for wildcards; please use '**' instead`);
131
138
  }
132
139
  }
133
140
  else {
@@ -137,36 +144,28 @@ function unescape(s) {
137
144
  }
138
145
  function interpConfig(rawcfg) {
139
146
  var _a, _b, _c, _d, _e;
140
- function interpPath(name, str) {
141
- try {
142
- return unescape(str);
143
- }
144
- catch (err) {
145
- throw `${name}: ${err}`;
146
- }
147
- }
148
147
  function interpRedirect(i, r) {
149
148
  var _a, _b;
150
- const rfrom = interpPath(`Redirect ${i}, from`, r.from);
151
- const rto = interpPath(`Redirect ${i}, to`, r.to);
149
+ const rfrom = withErrorMsg(() => unescape(r.from), msg => `from: ${msg}`);
150
+ const rto = withErrorMsg(() => unescape(r.to), msg => `to: ${msg}`);
152
151
  let redirect;
153
152
  if (rfrom.is_prefix) {
154
153
  // This is a bucket redirect
155
154
  const toURI = URI.parse(rto.s);
156
155
  if (toURI.error) {
157
- throw `Redirect ${i}, 'to': ${toURI.error}`;
156
+ throw new ErrorMsg(`'to': ${toURI.error}`);
158
157
  }
159
158
  if (toURI.scheme && toURI.scheme != "http" && toURI.scheme != "https") {
160
- throw `Redirect ${i}, 'to': unsupported URI scheme ${toURI.scheme}; http or https required`;
159
+ throw new ErrorMsg(`'to': unsupported URI scheme ${toURI.scheme}; http or https required`);
161
160
  }
162
161
  if (toURI.port) {
163
- throw `Redirect ${i}, 'to': specifying a port is not supported`;
162
+ throw new ErrorMsg(`'to': specifying a port is not supported`);
164
163
  }
165
164
  if (toURI.query) {
166
- throw `Redirect ${i}, 'to': specifying a query is not supported`;
165
+ throw new ErrorMsg(`'to': specifying a query is not supported`);
167
166
  }
168
167
  if (toURI.fragment) {
169
- throw `Redirect ${i}, 'to': specifying a fragment is not supported`;
168
+ throw new ErrorMsg(`'to': specifying a fragment is not supported`);
170
169
  }
171
170
  let path = (_a = toURI.path) !== null && _a !== void 0 ? _a : "";
172
171
  // - In case of an absolute URL e.g. `https://foo.net/abc`, toURI.path will
@@ -208,12 +207,13 @@ function interpConfig(rawcfg) {
208
207
  }
209
208
  else if ([200, 404].includes(r.status)) {
210
209
  if (toURI.host || toURI.scheme) {
211
- throw `Redirect ${i}: a status of 200 or 404 is not accepted while specifying a protocol and hostname in the destination`;
210
+ throw new ErrorMsg(`a status of 200 or 404 is not accepted while specifying` +
211
+ ` a protocol and hostname in the destination`);
212
212
  }
213
213
  bredirect.status = r.status;
214
214
  }
215
215
  else {
216
- throw `Redirect ${i}: unsupported status code ${r.status}`;
216
+ throw new ErrorMsg(`unsupported status code ${r.status}`);
217
217
  }
218
218
  }
219
219
  redirect = { kind: "bucket", r: bredirect };
@@ -221,15 +221,15 @@ function interpConfig(rawcfg) {
221
221
  else {
222
222
  // This is an object redirect
223
223
  if (r.force) {
224
- throw (`Redirect ${i}: 'force' is not supported for single redirections.\n` +
224
+ throw new ErrorMsg(`'force' is not supported for single redirections.\n` +
225
225
  "The source of the redirection must not exist already as a file.");
226
226
  }
227
227
  if (r.status) {
228
- throw (`Redirect ${i}: 'status' is not supported for single redirections.\n` +
228
+ throw new ErrorMsg(`'status' is not supported for single redirections.\n` +
229
229
  "All single redirections have status code 301.");
230
230
  }
231
231
  if (rto.is_prefix) {
232
- throw `Redirect ${i}: cannot use a prefix target in 'to' when 'from' is a single address`;
232
+ throw new ErrorMsg(`cannot use a prefix target in 'to' when 'from' is a single address`);
233
233
  }
234
234
  // if 'from' starts with a /, consider that the user meant to refer to an object
235
235
  // at the root of the bucket and not an object with "/" in its name. Thus we should
@@ -263,24 +263,26 @@ function interpConfig(rawcfg) {
263
263
  }
264
264
  return s !== null && s !== void 0 ? s : def;
265
265
  };
266
+ // use index.html as default index page
267
+ const index_page = withDefault(rawcfg.index_page, "index.html");
268
+ // use error.html as default error page
269
+ const error_page = withDefault(rawcfg.error_page, "error.html");
266
270
  let cfg = {
267
- // use index.html as default index if not specified explicitly
268
- index_page: withDefault(rawcfg.index_page, "index.html"),
269
- // use error.html as default error page
270
- error_page: withDefault(rawcfg.error_page, "error.html"),
271
+ index_page,
272
+ error_page,
271
273
  bucket_redirects: [],
272
274
  object_redirects: new Map(),
273
275
  cors_rules: [],
274
276
  };
275
277
  for (const [i, raw] of ((_a = rawcfg.redirects) !== null && _a !== void 0 ? _a : []).entries()) {
276
278
  // `i+1` is only used for display: start counting redirects from 1 instead of 0
277
- const r = interpRedirect(i + 1, raw);
279
+ const r = withErrorMsg(() => interpRedirect(i + 1, raw), msg => `Redirect ${i + 1}: ${msg}`);
278
280
  if (r.kind == "bucket") {
279
281
  cfg.bucket_redirects.push(r.r);
280
282
  }
281
283
  else {
282
284
  if (cfg.object_redirects.has(r.from)) {
283
- throw `Cannot have two redirects from the same path ${r.from}`;
285
+ throw new ErrorMsg(`Cannot have two redirects from the same path ${r.from}`);
284
286
  }
285
287
  cfg.object_redirects.set(r.from, r.to);
286
288
  }
@@ -300,7 +302,9 @@ function interpConfig(rawcfg) {
300
302
  }
301
303
  export function readConfigFile(filename) {
302
304
  return __awaiter(this, void 0, void 0, function* () {
303
- return interpConfig(interpRawConfig(yield readConfigFileObject(filename)));
305
+ const obj = yield readConfigFileObject(filename);
306
+ const raw = withErrorMsg(() => interpRawConfig(obj), msg => `Failed to parse ${filename}:\n${msg}`);
307
+ return interpConfig(raw);
304
308
  });
305
309
  }
306
310
  ////////////// Reading from a S3 bucket
@@ -314,22 +318,12 @@ export function getBucketConfig(bucket, files) {
314
318
  // but I don't know of a better way.)
315
319
  var _a, _b, _c, _d, _e, _f;
316
320
  const [website, cors, details] = yield Promise.all([
317
- bucket.client.send(new GetBucketWebsiteCommand({ Bucket: bucket.name })),
318
- bucket.client.send(new GetBucketCorsCommand({ Bucket: bucket.name })),
321
+ // 204 "No Content" is returned when there is no existing website config
322
+ wrapS3Call(`read the bucket website config`, [200, 204], () => bucket.client.send(new GetBucketWebsiteCommand({ Bucket: bucket.name }))),
323
+ // 204 "No Content" is returned when there are no existing CORS rules
324
+ wrapS3Call(`read the bucket CORS config`, [200, 204], () => bucket.client.send(new GetBucketCorsCommand({ Bucket: bucket.name }))),
319
325
  getBucketFilesDetails(bucket, files),
320
326
  ]);
321
- if (website && website.$metadata.httpStatusCode != 200) {
322
- throw `Error sending GetBucketWebsite: ${JSON.stringify(website)}`;
323
- }
324
- if (!cors ||
325
- (cors.$metadata.httpStatusCode != 200 &&
326
- cors.$metadata.httpStatusCode != 204)) {
327
- throw `Error sending GetBucketCors: ${JSON.stringify(cors)}`;
328
- }
329
- // 204 "No Content" is returned when there are no existing CORS rules
330
- if (cors.$metadata.httpStatusCode == 204) {
331
- cors.CORSRules = [];
332
- }
333
327
  // Collect object redirects
334
328
  const object_redirects = new Map();
335
329
  for (const [file, { redirect }] of details) {
@@ -340,7 +334,7 @@ export function getBucketConfig(bucket, files) {
340
334
  // Interpret bucket redirects
341
335
  if (website.RedirectAllRequestsTo) {
342
336
  // NB: garage does not currently support RedirectAllRequestsTo so this should never happen
343
- throw (`remote website configuration: RedirectAllRequestsTo is specified; ` +
337
+ throw new ErrorMsg(`remote website configuration: RedirectAllRequestsTo is specified; ` +
344
338
  `this is currently unsupported by dxfl`);
345
339
  }
346
340
  const index_page = (_a = website.IndexDocument) === null || _a === void 0 ? void 0 : _a.Suffix;
@@ -361,7 +355,8 @@ export function getBucketConfig(bucket, files) {
361
355
  }
362
356
  else {
363
357
  // not supported by garage
364
- throw `remote website configuration: unexpected value for 'HttpErrorCodeReturnedEquals': ${rule.Condition.HttpErrorCodeReturnedEquals}`;
358
+ throw new ErrorMsg(`remote website configuration: unexpected value for` +
359
+ ` 'HttpErrorCodeReturnedEquals': ${rule.Condition.HttpErrorCodeReturnedEquals}`);
365
360
  }
366
361
  }
367
362
  if (rule.Condition.KeyPrefixEquals) {
@@ -380,7 +375,7 @@ export function getBucketConfig(bucket, files) {
380
375
  }
381
376
  else {
382
377
  // currently not supported by garage
383
- throw (`remote website configuration: 'protocol' is neither http or https; ` +
378
+ throw new ErrorMsg(`remote website configuration: 'protocol' is neither http or https; ` +
384
379
  `this is currently unsupported by dxfl`);
385
380
  }
386
381
  }
@@ -472,13 +467,10 @@ export function putBucketWebsiteConfig(bucket, index_page, error_page, bucket_re
472
467
  WebsiteConfiguration.RoutingRules.push(rule);
473
468
  }
474
469
  }
475
- const resp = yield bucket.client.send(new PutBucketWebsiteCommand({
470
+ yield wrapS3Call(`write the bucket website config`, [200], () => bucket.client.send(new PutBucketWebsiteCommand({
476
471
  Bucket: bucket.name,
477
472
  WebsiteConfiguration,
478
- }));
479
- if (resp && resp.$metadata.httpStatusCode != 200) {
480
- throw resp;
481
- }
473
+ })));
482
474
  });
483
475
  }
484
476
  // applies CORS rules
@@ -493,12 +485,9 @@ export function putCorsRules(bucket, cors_rules) {
493
485
  ExposeHeaders: rule.expose_headers,
494
486
  });
495
487
  }
496
- const resp = yield bucket.client.send(new PutBucketCorsCommand({
488
+ yield wrapS3Call(`when writing the bucket CORS config`, [200], () => bucket.client.send(new PutBucketCorsCommand({
497
489
  Bucket: bucket.name,
498
490
  CORSConfiguration: { CORSRules },
499
- }));
500
- if (resp && resp.$metadata.httpStatusCode != 200) {
501
- throw resp;
502
- }
491
+ })));
503
492
  });
504
493
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dxfl",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
4
4
  "description": "",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Deuxfleurs Team <coucou@deuxfleurs.fr>",