@gnar-engine/cli 1.0.6 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gnar-engine/cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Gnar Engine Development Framework CLI: Project bootstrap, scaffolder & control plane.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import path from 'path';
2
+ import { fileURLToPath } from 'url';
2
3
 
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = path.dirname(__filename);
3
6
  export const gnarEngineCliConfig = {
4
7
 
5
8
  /**
6
9
  * The path the Gnar Engine service core should be found in the service containers
7
10
  */
8
- corePath: '/usr/gnar_engine/app/node_modules/@gnar-engine/core'
11
+ corePath: '/usr/gnar_engine/app/node_modules/@gnar-engine/core',
12
+ coreDevPath: path.join(__dirname, '../../core'),
9
13
 
10
14
  }
11
15
 
@@ -12,9 +12,11 @@ export function registerDevCommands(program) {
12
12
  .description('🛠️ Up Development Containers')
13
13
  .option('-b, --build', 'Build without cache')
14
14
  .option('-d, --detach', 'Run containers in background')
15
+ .option('-a, --attach-all', 'Attach all services including database and message queues for debugging')
15
16
  .option('-t --test', 'Run the tests with ephemeral databases')
16
17
  .option('--test-service <service>', 'Run the tests for the specified service with ephemeral databases (e.g. --test-service user)')
17
18
  .addOption(new Option('--core-dev').hideHelp())
19
+ .addOption(new Option('--bootstrap-dev').hideHelp())
18
20
  .action(async (options) => {
19
21
  let response = {};
20
22
 
@@ -39,8 +41,10 @@ export function registerDevCommands(program) {
39
41
  build: options.build || false,
40
42
  detach: options.detach || false,
41
43
  coreDev: options.coreDev || false,
44
+ bootstrapDev: options.bootstrapDev || false,
42
45
  test: options.test || false,
43
- testService: options.testService || ''
46
+ testService: options.testService || '',
47
+ attachAll: options.attachAll || false
44
48
  });
45
49
  } catch (err) {
46
50
  console.error("❌ Error running containers:", err.message);
@@ -3,10 +3,13 @@ import Docker from "dockerode";
3
3
  import process from "process";
4
4
  import fs from "fs/promises";
5
5
  import path from "path";
6
+ import { fileURLToPath } from 'url';
6
7
  import yaml from "js-yaml";
7
8
  import { gnarEngineCliConfig } from "../config.js";
9
+ import { buildImage, createContainer, createBridgeNetwork } from "../services/docker.js";
8
10
  import { directories } from "../config.js";
9
11
 
12
+
10
13
  const docker = new Docker();
11
14
 
12
15
  /**
@@ -19,14 +22,16 @@ const docker = new Docker();
19
22
  * @param {boolean} [options.build=false] - Whether to re-build images
20
23
  * @param {boolean} [options.detach=false] - Whether to run containers in background
21
24
  * @param {boolean} [options.coreDev=false] - Whether to run in core development mode (requires access to core source)
25
+ * @param {boolean} [options.bootstrapDev=false] - Whether to set the cli/src/bootstrap directory as the project directory
22
26
  * @param {boolean} [options.test=false] - Whether to run tests with ephemeral databases
23
27
  * @param {string} [options.testService=''] - The service to run tests for (only applicable if test=true)
24
28
  * @param {boolean} [options.removeOrphans=true] - Whether to remove orphaned containers
29
+ * @param {boolean} [options.attachAll=false] - Attach all services including database and message queues for debugging
25
30
  */
26
- export async function up({ projectDir, build = false, detach = false, coreDev = false, test = false, testService = '', removeOrphans = true }) {
31
+ export async function up({ projectDir, build = false, detach = false, coreDev = false, bootstrapDev = false, test = false, testService = '', removeOrphans = true, attachAll = false}) {
27
32
 
28
- // core dev
29
- if (coreDev) {
33
+ // bootstrap dev
34
+ if (bootstrapDev) {
30
35
  const fileDir = path.dirname(new URL(import.meta.url).pathname);
31
36
  projectDir = path.resolve(fileDir, "../../bootstrap/");
32
37
  }
@@ -56,50 +61,51 @@ export async function up({ projectDir, build = false, detach = false, coreDev =
56
61
 
57
62
  // create docker-compose.yml dynamically from parsed config and secrets
58
63
  const dockerComposePath = path.join(gnarHiddenDir, "docker-compose.dev.yml");
59
- const dockerCompose = await createDynamicDockerCompose({
64
+ const dockerCompose = await buildAndUpContainers({
60
65
  config: parsedConfig.config,
61
66
  secrets: parsedSecrets,
62
67
  gnarHiddenDir: gnarHiddenDir,
63
68
  projectDir: projectDir,
64
69
  coreDev: coreDev,
70
+ bootstrapDev: bootstrapDev,
65
71
  test: test,
66
- testService: testService
72
+ testService: testService,
73
+ attachAll: attachAll
67
74
  });
68
- await fs.writeFile(dockerComposePath, yaml.dump(dockerCompose));
69
-
70
- // up docker-compose
71
- const args = ["-f", dockerComposePath, "up"];
72
75
 
73
- if (build) {
74
- args.push("--build");
75
- }
76
-
77
- if (detach) {
78
- args.push("-d");
79
- }
80
-
81
- if (removeOrphans) {
82
- args.push("--remove-orphans")
83
- }
84
-
85
- const processRef = spawn(
86
- "docker-compose",
87
- args,
88
- {
89
- cwd: projectDir,
90
- stdio: "inherit",
91
- shell: "/bin/sh"
92
- }
93
- );
94
-
95
- // handle exit
96
- const exitCode = await new Promise((resolve) => {
97
- processRef.on("close", resolve);
98
- });
99
-
100
- if (exitCode !== 0) {
101
- throw new Error(`docker-compose up exited with code ${exitCode}`);
102
- }
76
+ // // up docker-compose
77
+ // const args = ["-f", dockerComposePath, "up"];
78
+ //
79
+ // if (build) {
80
+ // args.push("--build");
81
+ // }
82
+ //
83
+ // if (detach) {
84
+ // args.push("-d");
85
+ // }
86
+ //
87
+ // if (removeOrphans) {
88
+ // args.push("--remove-orphans")
89
+ // }
90
+
91
+ // const processRef = spawn(
92
+ // "docker-compose",
93
+ // args,
94
+ // {
95
+ // cwd: projectDir,
96
+ // stdio: "inherit",
97
+ // shell: "/bin/sh"
98
+ // }
99
+ // );
100
+ //
101
+ // // handle exit
102
+ // const exitCode = await new Promise((resolve) => {
103
+ // processRef.on("close", resolve);
104
+ // });
105
+ //
106
+ // if (exitCode !== 0) {
107
+ // throw new Error(`docker-compose up exited with code ${exitCode}`);
108
+ // }
103
109
  }
104
110
 
105
111
  /**
@@ -226,17 +232,23 @@ export async function createDynamicNginxConf({ config, serviceConfDir, projectDi
226
232
  * @param {string} gnarHiddenDir
227
233
  * @param {string} projectDir
228
234
  * @param {boolean} coreDev - Whether to volume mount the core source code
235
+ * @param {boolean} bootstrapDev - Whether to set the cli/src/bootstrap directory as the project directory
236
+ * @param {boolean} build - Whether to re-build images
229
237
  * @param {boolean} test - Whether to run tests with ephemeral databases
230
238
  * @param {string} testService - The service to run tests for (only applicable if test=true)
231
239
  * @param {boolean} attachAll - Whether to attach to all containers' stdio (otherwise databases and message queue are detached)
232
240
  */
233
- async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, projectDir, coreDev = false, test = false, testService, attachAll = false }) {
241
+ async function buildAndUpContainers({ config, secrets, gnarHiddenDir, projectDir, coreDev = false, bootstrapDev = false, build = false, test = false, testService, attachAll = false }) {
242
+
234
243
  let mysqlPortsCounter = 3306;
235
244
  let mongoPortsCounter = 27017;
236
245
  let mysqlHostsRequired = [];
237
246
  let mongoHostsRequired = [];
238
247
  const services = {};
239
248
 
249
+ console.log('======== g n a r e n g i n e ========');
250
+ console.log('⛏️ Starting development environment...');
251
+
240
252
  // test mode env var adjustments
241
253
  for (const svc of config.services) {
242
254
  if (test) {
@@ -247,7 +259,6 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
247
259
  if (secrets.services?.[svc.name]) {
248
260
  secrets.services[svc.name].NODE_ENV = 'test';
249
261
 
250
- console.log(testService, svc.name);
251
262
  if (testService && svc.name === testService) {
252
263
  secrets.services[svc.name].RUN_TESTS = 'true';
253
264
  }
@@ -255,63 +266,92 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
255
266
  }
256
267
  }
257
268
 
269
+ // create bridge network
270
+ const networkName = `ge-${config.environment}-${config.namespace}`;
271
+ createBridgeNetwork({
272
+ name: networkName
273
+ })
274
+
258
275
  // provision the provisioner service
259
- services['provisioner'] = {
260
- container_name: `ge-${config.environment}-${config.namespace}-provisioner`,
261
- image: `ge-${config.environment}-${config.namespace}-provisioner`,
262
- build: {
276
+ const provisionerTag = `ge-${config.environment}-${config.namespace}-provisioner`;
277
+
278
+ if (build) {
279
+ await buildImage({
263
280
  context: directories.provisioner,
264
- dockerfile: `./Dockerfile`
265
- },
266
- environment: {
267
- PROVISIONER_SECRETS: JSON.stringify(secrets)
268
- },
269
- volumes: [
270
- `${directories.provisioner}/src:/usr/gnar_engine/app/src`
271
- ],
272
- restart: 'no',
273
- attach: attachAll
281
+ dockerfile: 'Dockerfile',
282
+ imageTag: provisionerTag
283
+ });
274
284
  }
275
285
 
286
+ const __filename = fileURLToPath(import.meta.url);
287
+ const __dirname = path.dirname(__filename);
288
+
289
+ const provisionerBinds = [
290
+ `${path.resolve(__dirname, '../provisioner', 'src')}:/usr/gnar_engine/app/src`
291
+ ];
292
+
276
293
  if (coreDev) {
277
- services['provisioner'].volumes.push(`../../../core/:${gnarEngineCliConfig.corePath}`);
294
+ provisionerBinds.push(`${gnarEngineCliConfig.coreDevPath}:${gnarEngineCliConfig.corePath}`);
278
295
  }
279
296
 
280
- // nginx
281
- services['nginx'] = {
297
+ const provisioner = await createContainer({
298
+ name: provisionerTag,
299
+ image: provisionerTag,
300
+ env: {
301
+ PROVISIONER_SECRETS: JSON.stringify(secrets)
302
+ },
303
+ ports: {},
304
+ binds: provisionerBinds,
305
+ restart: 'no',
306
+ attach: attachAll,
307
+ network: networkName
308
+ });
309
+ services[provisionerTag] = provisioner;
310
+
311
+ // Nginx
312
+ const nginxName = `ge-${config.environment}-${config.namespace}-nginx`;
313
+ services[nginxName] = await createContainer({
314
+ name: nginxName,
282
315
  image: 'nginx:latest',
283
- container_name: `ge-${config.environment}-${config.namespace}-nginx`,
284
- ports: [
285
- "80:80",
286
- "443:443"
287
- ],
288
- volumes: [
316
+ ports: { 80: 80, 443: 443 },
317
+ binds: [
289
318
  `${gnarHiddenDir}/nginx/nginx.conf:/etc/nginx/nginx.conf`,
290
319
  `${gnarHiddenDir}/nginx/service_conf:/etc/nginx/service_conf`
291
320
  ],
292
- restart: 'always',
293
- attach: attachAll
294
- }
321
+ attach: attachAll,
322
+ network: networkName
323
+ });
295
324
 
296
- // rabbit
297
- services['rabbitmq'] = {
325
+ // Rabbit MQ
326
+ const rabbitMqName = `ge-${config.environment}-${config.namespace}-rabbitmq`;
327
+ services[rabbitMqName] = await createContainer({
328
+ name: rabbitMqName,
298
329
  image: 'rabbitmq:management',
299
- container_name: `ge-${config.environment}-${config.namespace}-rabbitmq`,
300
- ports: [
301
- "5672:5672",
302
- "15672:15672"
303
- ],
304
- environment: {
330
+ env: {
305
331
  RABBITMQ_DEFAULT_USER: secrets.global.RABBITMQ_USER || '',
306
332
  RABBITMQ_DEFAULT_PASS: secrets.global.RABBITMQ_PASS || ''
307
333
  },
308
- restart: 'always',
309
- attach: attachAll
310
- }
334
+ ports: { 5672: 5672, 15672: 15672 },
335
+ binds: [],
336
+ attach: attachAll,
337
+ network: networkName,
338
+ aliases: ['rabbitmq']
339
+ });
311
340
 
312
341
  // services
313
342
  for (const svc of config.services) {
314
343
 
344
+ // build service image
345
+ const svcTag = `ge-${config.environment}-${config.namespace}-${svc.name}`;
346
+
347
+ if (build) {
348
+ await buildImage({
349
+ context: path.resolve(projectDir, 'services', svc.name),
350
+ dockerfile: 'Dockerfile',
351
+ imageTag: svcTag
352
+ });
353
+ }
354
+
315
355
  // env variables
316
356
  const serviceEnvVars = secrets.services?.[svc.name] || {};
317
357
  const localisedServiceEnvVars = {};
@@ -333,29 +373,35 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
333
373
  }
334
374
  }
335
375
 
336
- // service block
337
- services[`${svc.name}-service`] = {
338
- container_name: `ge-${config.environment}-${config.namespace}-${svc.name}`,
339
- image: `ge-${config.environment}-${config.namespace}-${svc.name}`,
340
- build: {
341
- context: projectDir,
342
- dockerfile: `./services/${svc.name}/Dockerfile`
343
- },
344
- command: svc.command || [],
345
- environment: env,
346
- ports: svc.ports || [],
347
- depends_on: svc.depends_on || [],
348
- volumes: [
349
- `${projectDir}/services/${svc.name}/src:/usr/gnar_engine/app/src`
350
- ],
351
- restart: 'always'
352
- };
353
-
354
376
  // add the core source code mount if in coreDev mode
377
+ const serviceVolumes = [
378
+ `${path.resolve(projectDir, 'services', svc.name, 'src')}:/usr/gnar_engine/app/src`
379
+ ];
380
+
355
381
  if (coreDev) {
356
- services[`${svc.name}-service`].volumes.push(`../../../core/:${gnarEngineCliConfig.corePath}`);
382
+ serviceVolumes.push(`${gnarEngineCliConfig.coreDevPath}:${gnarEngineCliConfig.corePath}`);
383
+ }
384
+
385
+ // split from "port:port" to { port: port }
386
+ const ports = {};
387
+ for (const portMapping of svc.ports || []) {
388
+ const [hostPort, containerPort] = portMapping.split(':').map(p => parseInt(p, 10));
389
+ ports[containerPort] = hostPort;
357
390
  }
358
391
 
392
+ services[svcTag] = await createContainer({
393
+ name: svcTag,
394
+ image: svcTag,
395
+ command: svc.command || [],
396
+ env: env,
397
+ ports: ports,
398
+ binds: serviceVolumes,
399
+ restart: 'always',
400
+ attach: true,
401
+ network: networkName,
402
+ aliases: [`${svc.name}-service`]
403
+ });
404
+
359
405
  // check if mysql service required
360
406
  if (
361
407
  serviceEnvVars.MYSQL_HOST &&
@@ -380,29 +426,30 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
380
426
  continue;
381
427
  }
382
428
 
383
- services[host] = {
384
- container_name: `ge-${config.environment}-${config.namespace}-${host}`,
429
+ const mysqlContainerName = `ge-${config.environment}-${config.namespace}-${host}`;
430
+ services[mysqlContainerName] = await createContainer({
431
+ name: `ge-${config.environment}-${config.namespace}-${host}`,
385
432
  image: 'mysql',
386
- ports: [
387
- `${mysqlPortsCounter}:${mysqlPortsCounter}`
388
- ],
389
- restart: 'always',
390
- environment: {
433
+ env: {
391
434
  MYSQL_HOST: host,
392
435
  MYSQL_ROOT_PASSWORD: secrets.provision.MYSQL_ROOT_PASSWORD
393
436
  },
394
- volumes: [
437
+ ports: {
438
+ [mysqlPortsCounter]: mysqlPortsCounter
439
+ },
440
+ binds: [
395
441
  `${gnarHiddenDir}/data/${host}-data:/var/lib/mysql`
396
442
  ],
397
- attach: attachAll
398
- };
443
+ restart: 'always',
444
+ attach: attachAll,
445
+ network: networkName,
446
+ aliases: [host]
447
+ });
399
448
 
400
449
  mysqlPortsCounter++;
401
450
  }
402
-
403
- services['provisioner'].depends_on = [...new Set(mysqlHostsRequired)];
404
451
  }
405
-
452
+
406
453
  // add mongo hosts if required
407
454
  if (mongoHostsRequired.length > 0) {
408
455
  for (const host of mongoHostsRequired) {
@@ -410,33 +457,37 @@ async function createDynamicDockerCompose({ config, secrets, gnarHiddenDir, proj
410
457
  continue;
411
458
  }
412
459
 
413
- services[host] = {
414
- container_name: `ge-${config.environment}-${config.namespace}-${host}`,
460
+ const mongoContainerName = `ge-${config.environment}-${config.namespace}-${host}`;
461
+ services[mongoContainerName] = await createContainer({
462
+ name: `ge-${config.environment}-${config.namespace}-${host}`,
415
463
  image: 'mongo:latest',
416
- ports: [
417
- `${mongoPortsCounter}:27017`
418
- ],
419
- restart: 'always',
420
- environment: {
464
+ env: {
421
465
  MONGO_INITDB_ROOT_USERNAME: 'root',
422
466
  MONGO_INITDB_ROOT_PASSWORD: secrets.provision.MONGO_ROOT_PASSWORD
423
467
  },
424
- volumes: [
468
+ ports: {
469
+ [mongoPortsCounter]: 27017
470
+ },
471
+ binds: [
425
472
  `${gnarHiddenDir}/data/${host}-data:/data/db`,
426
- './mongo-init-scripts:/docker-entrypoint-initdb.d'
473
+ //'./mongo-init-scripts:/docker-entrypoint-initdb.d'
427
474
  ],
428
- attach: attachAll
429
- };
475
+ restart: 'always',
476
+ attach: attachAll,
477
+ network: networkName,
478
+ aliases: [host]
479
+ });
430
480
 
431
481
  // increment mongo port for next service as required
432
482
  mongoPortsCounter++;
433
483
  }
434
484
  }
435
485
 
436
- return {
437
- version: "3.9",
438
- services
439
- }
486
+ // start the containers
487
+ Object.keys(services).forEach(async (key) => {
488
+ const container = services[key];
489
+ container.start();
490
+ });
440
491
  }
441
492
 
442
493
  /**
@@ -0,0 +1,173 @@
1
+ import Docker from 'dockerode';
2
+ import { Writable } from 'stream';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+
6
+ const docker = new Docker();
7
+
8
+ /**
9
+ * Build Docker image
10
+ *
11
+ * @param {Object} options
12
+ * @param {string} options.context - The build context directory
13
+ * @param {string} options.dockerfile - The Dockerfile path relative to the context
14
+ * @param {string} options.imageTag - The tag to assign to the built image
15
+ */
16
+ export async function buildImage({ context, dockerfile, imageTag }) {
17
+
18
+ console.log('Building image...', imageTag);
19
+
20
+ const tarStream = await docker.buildImage(
21
+ {
22
+ context: context,
23
+ src: fs.readdirSync(context)
24
+ },
25
+ {
26
+ t: imageTag,
27
+ dockerfile
28
+ }
29
+ );
30
+
31
+ await new Promise((resolve, reject) => {
32
+ docker.modem.followProgress(tarStream, (err) => (err ? reject(err) : resolve()));
33
+ });
34
+
35
+ console.log('Built image:', imageTag);
36
+ }
37
+
38
+ /**
39
+ * Create docker container
40
+ *
41
+ * @param {Object} options
42
+ * @param {string} options.name - Container name
43
+ * @param {string} options.image - Docker image to use
44
+ * @param {Array} [options.command] - Command to run in the container
45
+ * @param {Object} [options.env] - Environment variables
46
+ * @param {Object} [options.ports] - Port mappings (containerPort: hostPort)
47
+ * @param {Array} [options.binds] - Volume bindings
48
+ * @param {string} [options.restart] - Restart policy
49
+ * @param {boolean} [options.attach] - Whether to attach to container output
50
+ * @param {string} [options.network] - Network name
51
+ * @returns {Promise<Docker.Container>} - The started container
52
+ */
53
+ export async function createContainer({ name, image, command = [], env = {}, ports = {}, binds = [], restart = 'always', attach = true, network, aliases = [] }) {
54
+
55
+ // remove container first
56
+ try {
57
+ const existingContainer = docker.getContainer(name);
58
+ await existingContainer.inspect();
59
+ await existingContainer.remove({ force: true });
60
+ } catch (err) {
61
+ // Container does not exist, ignore
62
+ }
63
+
64
+ // create container
65
+ const containerConfig = {
66
+ name,
67
+ Image: image,
68
+ Cmd: command,
69
+ Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
70
+ HostConfig: {
71
+ RestartPolicy: { Name: restart },
72
+ Binds: binds,
73
+ PortBindings: Object.fromEntries(
74
+ Object.entries(ports).map(([cPort, hPort]) => [
75
+ `${cPort}/tcp`,
76
+ [{ HostPort: String(hPort) }]
77
+ ])
78
+ ),
79
+ },
80
+ ExposedPorts: Object.fromEntries(
81
+ Object.keys(ports).map(p => [`${p}/tcp`, {}])
82
+ )
83
+ };
84
+
85
+ const container = await docker.createContainer(containerConfig);
86
+
87
+ // Attach logs before starting the container
88
+ if (attach) {
89
+ const stream = await container.attach({ stream: true, stdout: true, stderr: true, logs: true });
90
+ const stdoutStream = createPrefixStream(name, process.stdout);
91
+ const stderrStream = createPrefixStream(name, process.stderr);
92
+
93
+ container.modem.demuxStream(stream, stdoutStream, stderrStream);
94
+ }
95
+
96
+ await docker.getNetwork(network).connect({
97
+ Container: container.id,
98
+ EndpointConfig: {
99
+ Aliases: aliases
100
+ }
101
+ });
102
+
103
+ return container;
104
+ }
105
+
106
+ /**
107
+ * Create network
108
+ *
109
+ * @param {String} name - Network name
110
+ */
111
+ export async function createBridgeNetwork({ name }) {
112
+ try {
113
+ await docker.createNetwork({
114
+ Name: name,
115
+ Driver: 'bridge',
116
+ CheckDuplicate: true
117
+ });
118
+ } catch (err) {
119
+ if (err.statusCode === 409) {
120
+ // network already exists, ignore
121
+ return;
122
+ }
123
+ console.error(err);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Tansform stream
129
+ *
130
+ * @param {String} name - Container name
131
+ * @returns {Transform} - Transform stream
132
+ */
133
+ function createPrefixStream(name, targetStream) {
134
+ const color = colorForName(name);
135
+ const labelWidth = 38;
136
+ name = '[' + name + ']';
137
+ const paddedName = name.padEnd(labelWidth, ' ');
138
+
139
+ return new Writable({
140
+ write(chunk, encoding, callback) {
141
+ const lines = chunk.toString().split(/\r?\n/);
142
+ for (const line of lines) {
143
+ if (line.trim()) {
144
+ targetStream.write(`${color}${paddedName}${RESET} ${line}\n`);
145
+ }
146
+ }
147
+ callback();
148
+ }
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Colours for container name
154
+ */
155
+ const COLORS = [
156
+ '\x1b[31m', // red
157
+ '\x1b[32m', // green
158
+ '\x1b[33m', // yellow
159
+ '\x1b[34m', // blue
160
+ '\x1b[35m', // magenta
161
+ '\x1b[36m', // cyan
162
+ '\x1b[37m', // white
163
+ ];
164
+
165
+ const RESET = '\x1b[0m';
166
+
167
+ function colorForName(name) {
168
+ let hash = 0;
169
+ for (let i = 0; i < name.length; i++) {
170
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
171
+ }
172
+ return COLORS[Math.abs(hash) % COLORS.length];
173
+ }