kourou 1.2.0 → 1.4.0-dev.1

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
@@ -27,7 +27,7 @@ $ npm install -g kourou
27
27
  $ kourou COMMAND
28
28
  running command...
29
29
  $ kourou (-v|--version|version)
30
- kourou/1.2.0 linux-x64 node-v22.16.0
30
+ kourou/1.4.0-dev.1 linux-x64 node-v24.18.0
31
31
  $ kourou --help [COMMAND]
32
32
  USAGE
33
33
  $ kourou COMMAND
@@ -364,8 +364,11 @@ ARGUMENTS
364
364
  DESTINATION Directory to scaffold the app
365
365
 
366
366
  OPTIONS
367
- --flavor=flavor [default: generic] Template flavor ("generic", "iot").
367
+ --flavor=flavor [default: generic] Template flavor ("generic", "iot", "hypervision").
368
368
  --help show CLI help
369
+
370
+ --token=token GitHub token used to clone private template repositories.
371
+ Defaults to the GITHUB_TOKEN environment variable, then to the GitHub CLI credentials.
369
372
  ```
370
373
 
371
374
  _See code: [lib/commands/app/scaffold.js](lib/commands/app/scaffold.js)_
@@ -7,6 +7,7 @@ export default class AppScaffold extends Kommand {
7
7
  static flags: {
8
8
  help: import("@oclif/parser/lib/flags").IBooleanFlag<void>;
9
9
  flavor: flags.IOptionFlag<string>;
10
+ token: flags.IOptionFlag<string | undefined>;
10
11
  };
11
12
  static args: {
12
13
  name: string;
@@ -14,9 +15,28 @@ export default class AppScaffold extends Kommand {
14
15
  required: boolean;
15
16
  }[];
16
17
  runSafe(): Promise<void>;
17
- getRepo(flavor: string): "template-kuzzle-project" | "template-kiotp-project";
18
+ getRepo(flavor: string): "template-kuzzle-project" | "template-kiotp-project" | "template-hypervision-project";
18
19
  checkDestination(destination: string): Promise<void>;
19
20
  prepareTemplate(): Promise<void>;
21
+ /**
22
+ * Returns a GitHub token allowing to clone private template repositories,
23
+ * or undefined if none can be found.
24
+ *
25
+ * Looked up in the --token flag, then in the usual environment variables,
26
+ * then in the GitHub CLI credentials.
27
+ */
28
+ getToken(): Promise<string | undefined>;
29
+ /**
30
+ * Returns the URLs to try to clone the template repository from, in order.
31
+ *
32
+ * Some templates are hosted in private repositories, so we need credentials:
33
+ * either a token, or the SSH key of the user.
34
+ */
35
+ getCloneUrls(repo: string): Promise<string[]>;
36
+ /**
37
+ * Removes any token from a string before displaying it.
38
+ */
39
+ hideToken(message: string): string;
20
40
  cloneTemplate(flavor: string): Promise<void>;
21
41
  copyTemplate(destination: string): Promise<void>;
22
42
  cleanup(destination: string): Promise<void>;
@@ -47,6 +47,8 @@ class AppScaffold extends common_1.Kommand {
47
47
  return "template-kuzzle-project";
48
48
  case "iot":
49
49
  return "template-kiotp-project";
50
+ case "hypervision":
51
+ return "template-hypervision-project";
50
52
  default:
51
53
  return "template-kuzzle-project";
52
54
  }
@@ -69,12 +71,81 @@ class AppScaffold extends common_1.Kommand {
69
71
  async prepareTemplate() {
70
72
  await (0, execute_1.execute)("rm", "-rf", this.templatesDir);
71
73
  }
74
+ /**
75
+ * Returns a GitHub token allowing to clone private template repositories,
76
+ * or undefined if none can be found.
77
+ *
78
+ * Looked up in the --token flag, then in the usual environment variables,
79
+ * then in the GitHub CLI credentials.
80
+ */
81
+ async getToken() {
82
+ const token = this.flags.token || process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
83
+ if (token) {
84
+ return token;
85
+ }
86
+ try {
87
+ const { stdout } = await (0, execute_1.execute)("gh", "auth", "token");
88
+ return stdout.trim() || undefined;
89
+ }
90
+ catch (error) {
91
+ // GitHub CLI is not installed or not authenticated
92
+ return undefined;
93
+ }
94
+ }
95
+ /**
96
+ * Returns the URLs to try to clone the template repository from, in order.
97
+ *
98
+ * Some templates are hosted in private repositories, so we need credentials:
99
+ * either a token, or the SSH key of the user.
100
+ */
101
+ async getCloneUrls(repo) {
102
+ const urls = [];
103
+ const token = await this.getToken();
104
+ if (token) {
105
+ urls.push(`https://x-access-token:${token}@github.com/kuzzleio/${repo}`);
106
+ }
107
+ urls.push(`git@github.com:kuzzleio/${repo}`);
108
+ urls.push(`https://github.com/kuzzleio/${repo}`);
109
+ return urls;
110
+ }
111
+ /**
112
+ * Removes any token from a string before displaying it.
113
+ */
114
+ hideToken(message) {
115
+ return message.replace(/x-access-token:[^@]*@/g, "x-access-token:***@");
116
+ }
72
117
  async cloneTemplate(flavor) {
73
118
  const repo = this.getRepo(flavor);
74
- await (0, execute_1.execute)("git", "clone", "--depth=1", `https://github.com/kuzzleio/${repo}`, "--branch", "main", "--single-branch", this.templatesDir);
119
+ const urls = await this.getCloneUrls(repo);
120
+ let lastError;
121
+ for (const url of urls) {
122
+ try {
123
+ await (0, execute_1.execute)("git", "clone", "--depth=1", url, "--branch", "stable", "--single-branch", this.templatesDir, {
124
+ env: Object.assign(Object.assign({}, process.env), {
125
+ // Never prompt for credentials, we want to fail fast and try
126
+ // the next URL instead of hanging on a password prompt
127
+ GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ||
128
+ "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new" }),
129
+ });
130
+ return;
131
+ }
132
+ catch (error) {
133
+ // Clone failed (e.g. the repository is private and those credentials
134
+ // are not allowed to read it), let's try the next URL
135
+ lastError = error;
136
+ await (0, execute_1.execute)("rm", "-rf", this.templatesDir);
137
+ }
138
+ }
139
+ throw new Error(`Unable to clone the template repository "kuzzleio/${repo}". ` +
140
+ "If this repository is private, provide credentials with the --token flag, " +
141
+ "the GITHUB_TOKEN environment variable, the GitHub CLI (gh auth login) " +
142
+ `or a SSH key allowed to read it.\n${this.hideToken((lastError === null || lastError === void 0 ? void 0 : lastError.message) || "")}`);
75
143
  }
76
144
  async copyTemplate(destination) {
77
- await (0, execute_1.execute)("cp", "-r", `${this.templatesDir}/`, `${destination}/`);
145
+ // -R (and not -r) so symlinks contained in the template are copied as
146
+ // symlinks instead of being dereferenced (BSD cp -r follows them and fails
147
+ // on dangling symlinks)
148
+ await (0, execute_1.execute)("cp", "-R", `${this.templatesDir}/`, `${destination}/`);
78
149
  }
79
150
  async cleanup(destination) {
80
151
  await (0, execute_1.execute)("rm", "-rf", this.templatesDir);
@@ -88,7 +159,11 @@ AppScaffold.flags = {
88
159
  help: command_1.flags.help(),
89
160
  flavor: command_1.flags.string({
90
161
  default: "generic",
91
- description: `Template flavor ("generic", "iot").`,
162
+ description: `Template flavor ("generic", "iot", "hypervision").`,
163
+ }),
164
+ token: command_1.flags.string({
165
+ description: `GitHub token used to clone private template repositories.
166
+ Defaults to the GITHUB_TOKEN environment variable, then to the GitHub CLI credentials.`,
92
167
  }),
93
168
  };
94
169
  AppScaffold.args = [
package/lib/common.js CHANGED
@@ -70,6 +70,7 @@ class Kommand extends command_1.Command {
70
70
  this.log(chalk_1.default.red(`[X] ${message}`));
71
71
  }
72
72
  async run() {
73
+ var _a, _b, _c, _d, _e;
73
74
  const kommand = this.constructor;
74
75
  const result = this.parse(kommand);
75
76
  this.args = result.args;
@@ -102,7 +103,13 @@ class Kommand extends command_1.Command {
102
103
  const errorLink = typeof error.id === "string" && error.id.split(".").length === 3
103
104
  ? ` (https://docs.kuzzle.io/core/2/api/errors/error-codes/${error.id.split(".")[0]})`
104
105
  : "";
105
- this.logKo(`Error stack: \n${stack || error.message}\n\nError status: ${error.status}\n\nError id: ${error.id}${errorLink}`);
106
+ // Elasticsearch client errors keep their details in "meta", they would
107
+ // be lost otherwise
108
+ const esBody = (_b = (_a = error.meta) === null || _a === void 0 ? void 0 : _a.body) !== null && _b !== void 0 ? _b : error.body;
109
+ const esDetails = esBody
110
+ ? `\n\nElasticsearch response: ${JSON.stringify(esBody)}`
111
+ : "";
112
+ this.logKo(`Error stack: \n${stack || error.message}${esDetails}\n\nError status: ${(_d = (_c = error.status) !== null && _c !== void 0 ? _c : error.statusCode) !== null && _d !== void 0 ? _d : (_e = error.meta) === null || _e === void 0 ? void 0 : _e.statusCode}\n\nError id: ${error.id}${errorLink}`);
106
113
  if (Array.isArray(error.errors)) {
107
114
  for (const e of error.errors) {
108
115
  this.logKo(`${e.document._id} : ${e.reason}`);
@@ -33,6 +33,9 @@ function execute(...args) {
33
33
  process.stdout.on("data", (data) => (stdout += data.toString()));
34
34
  process.stderr.on("data", (data) => (stderr += data.toString()));
35
35
  const executor = new Promise((resolve, reject) => {
36
+ // Command cannot be spawned (e.g. binary not installed).
37
+ // Without this listener, Node throws the error instead of rejecting.
38
+ process.on("error", (error) => reject(error));
36
39
  process.on("close", (code) => {
37
40
  if (code === 0) {
38
41
  resolve({ stdout, stderr, exitCode: code });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Splits documents into chunks so a bulk request never exceeds the
3
+ * Elasticsearch "http.max_content_length" limit (100mb by default).
4
+ */
5
+ export declare function chunkDocuments(docs: any[], chunkSize?: number): Generator<any[]>;
6
+ /**
7
+ * A bulk request answers with a 200 even when some documents were rejected,
8
+ * so the per-item errors have to be checked explicitly.
9
+ */
10
+ export declare function throwOnBulkErrors(response: any): void;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.throwOnBulkErrors = exports.chunkDocuments = void 0;
4
+ const DEFAULT_CHUNK_SIZE = 1000;
5
+ /**
6
+ * Splits documents into chunks so a bulk request never exceeds the
7
+ * Elasticsearch "http.max_content_length" limit (100mb by default).
8
+ */
9
+ function* chunkDocuments(docs, chunkSize = DEFAULT_CHUNK_SIZE) {
10
+ const size = chunkSize > 0 ? chunkSize : DEFAULT_CHUNK_SIZE;
11
+ for (let i = 0; i < docs.length; i += size) {
12
+ yield docs.slice(i, i + size);
13
+ }
14
+ }
15
+ exports.chunkDocuments = chunkDocuments;
16
+ /**
17
+ * A bulk request answers with a 200 even when some documents were rejected,
18
+ * so the per-item errors have to be checked explicitly.
19
+ */
20
+ function throwOnBulkErrors(response) {
21
+ if (!response || !response.errors) {
22
+ return;
23
+ }
24
+ const failures = (response.items || [])
25
+ .map((item) => item.index || item.create || item.update || item.delete)
26
+ .filter((item) => item && item.error);
27
+ if (failures.length === 0) {
28
+ return;
29
+ }
30
+ const [first] = failures;
31
+ const error = new Error(`${failures.length} document(s) rejected by Elasticsearch. First failure on "${first._id}": ${first.error.type} - ${first.error.reason}`);
32
+ error.errors = failures.map((item) => ({
33
+ document: { _id: item._id },
34
+ reason: `${item.error.type} - ${item.error.reason}`,
35
+ }));
36
+ throw error;
37
+ }
38
+ exports.throwOnBulkErrors = throwOnBulkErrors;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Elasticsearch7 = void 0;
4
4
  const sdk_es7_1 = require("sdk-es7");
5
+ const bulk_1 = require("./bulk");
5
6
  class Elasticsearch7 {
6
7
  constructor(url, options) {
7
8
  const { auth, url: cleanedUrl } = this.extractCredentials(url);
@@ -93,13 +94,20 @@ class Elasticsearch7 {
93
94
  }
94
95
  }
95
96
  async writeData(index, docs) {
96
- const bulk = [];
97
- for (const doc of docs) {
98
- bulk.push({ index: { _index: index, _id: doc._id } });
99
- bulk.push(doc._source);
97
+ let count = 0;
98
+ // Documents are sent by chunks, otherwise the bulk request may exceed
99
+ // the Elasticsearch "http.max_content_length" limit (100mb by default)
100
+ for (const chunk of (0, bulk_1.chunkDocuments)(docs, this.options.batchSize)) {
101
+ const bulk = [];
102
+ for (const doc of chunk) {
103
+ bulk.push({ index: { _index: index, _id: doc._id } });
104
+ bulk.push(doc._source);
105
+ }
106
+ const { body } = await this.client.bulk({ body: bulk });
107
+ (0, bulk_1.throwOnBulkErrors)(body);
108
+ count += bulk.length / 2;
100
109
  }
101
- await this.client.bulk({ body: bulk });
102
- return bulk.length / 2;
110
+ return count;
103
111
  }
104
112
  async clear() {
105
113
  await this.client.indices.delete({ index: "_all" });
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Elasticsearch8 = void 0;
4
4
  const sdk_es8_1 = require("sdk-es8");
5
+ const bulk_1 = require("./bulk");
5
6
  class Elasticsearch8 {
6
7
  constructor(url, options) {
7
8
  const { auth, url: cleanedUrl } = this.extractCredentials(url);
@@ -95,13 +96,20 @@ class Elasticsearch8 {
95
96
  }
96
97
  }
97
98
  async writeData(index, docs) {
98
- const bulk = [];
99
- for (const doc of docs) {
100
- bulk.push({ index: { _index: index, _id: doc._id } });
101
- bulk.push(doc._source);
99
+ let count = 0;
100
+ // Documents are sent by chunks, otherwise the bulk request may exceed
101
+ // the Elasticsearch "http.max_content_length" limit (100mb by default)
102
+ for (const chunk of (0, bulk_1.chunkDocuments)(docs, this.options.batchSize)) {
103
+ const bulk = [];
104
+ for (const doc of chunk) {
105
+ bulk.push({ index: { _index: index, _id: doc._id } });
106
+ bulk.push(doc._source);
107
+ }
108
+ const response = await this.client.bulk({ body: bulk });
109
+ (0, bulk_1.throwOnBulkErrors)(response);
110
+ count += bulk.length / 2;
102
111
  }
103
- await this.client.bulk({ body: bulk });
104
- return bulk.length / 2;
112
+ return count;
105
113
  }
106
114
  async clear() {
107
115
  const indices = await this.listIndices();
@@ -1,4 +1,5 @@
1
1
  import { IndexSpecification } from "../types";
2
+ export * from "./bulk";
2
3
  export * from "./elasticsearch7";
3
4
  export * from "./elasticsearch8";
4
5
  export * from "./file";
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isURL = void 0;
4
4
  const tslib_1 = require("tslib");
5
+ (0, tslib_1.__exportStar)(require("./bulk"), exports);
5
6
  (0, tslib_1.__exportStar)(require("./elasticsearch7"), exports);
6
7
  (0, tslib_1.__exportStar)(require("./elasticsearch8"), exports);
7
8
  (0, tslib_1.__exportStar)(require("./file"), exports);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kourou",
3
3
  "description": "The CLI that helps you manage your Kuzzle instances",
4
- "version": "1.2.0",
4
+ "version": "1.4.0-dev.1",
5
5
  "author": "The Kuzzle Team <support@kuzzle.io>",
6
6
  "bin": {
7
7
  "kourou": "./bin/run"
@@ -57,7 +57,7 @@
57
57
  "@types/listr": "0.14.2",
58
58
  "@types/mocha": "8.2.2",
59
59
  "@types/ndjson": "2.0.0",
60
- "@types/node": "18.19.0",
60
+ "@types/node": "20.14.15",
61
61
  "@types/node-emoji": "1.8.1",
62
62
  "@types/node-fetch": "2.6.1",
63
63
  "@types/tar": "6.1.3",
@@ -75,15 +75,11 @@
75
75
  "should": "13.2.3",
76
76
  "source-map-support": "0.5.19",
77
77
  "ts-node": "10.9.*",
78
- "semantic-release-config-kuzzle": "1.0.0",
79
- "semantic-release-slack-bot": "4.0.2",
80
- "@semantic-release/changelog": "6.0.3",
81
- "@semantic-release/commit-analyzer": "13.0.0",
82
- "@semantic-release/git": "10.0.1",
83
- "@semantic-release/release-notes-generator": "14.0.1"
78
+ "semantic-release": "25.0.8",
79
+ "semantic-release-config-kuzzle": "1.7.0"
84
80
  },
85
81
  "engines": {
86
- "node": ">=18.0.0"
82
+ "node": ">=20.0.0"
87
83
  },
88
84
  "files": [
89
85
  "/bin",