@ricardohsmello/mongodb-cli-lab 1.0.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.
Files changed (2) hide show
  1. package/index.js +2694 -0
  2. package/package.json +13 -0
package/index.js ADDED
@@ -0,0 +1,2694 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync } from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+ import { execFileSync } from "node:child_process";
9
+ import { Command } from "commander";
10
+ import inquirer from "inquirer";
11
+
12
+ const DEFAULT_STORAGE_PATH = "./mongodb-cli-lab";
13
+ const DEFAULT_PROJECT_NAME = "mongodb-cli-lab";
14
+ const COMPOSE_PROJECT_NAME = sanitizeProjectName(DEFAULT_PROJECT_NAME);
15
+ const INTERNAL_MONGO_PORT = 27017;
16
+ const CONFIG_SERVER_MEMBERS = 3;
17
+ const STATE_FILE_NAME = ".mongodb-cli-lab-state.json";
18
+ const GLOBAL_STATE_DIR = path.join(os.homedir(), ".mongodb-cli-lab");
19
+ let dockerComposeCommand = null;
20
+ const DOCS = {
21
+ sharding: "https://www.mongodb.com/docs/manual/sharding/",
22
+ replication: "https://www.mongodb.com/docs/manual/replication/",
23
+ shardKey: "https://www.mongodb.com/docs/manual/core/sharding-shard-key/",
24
+ hashedSharding: "https://www.mongodb.com/docs/manual/core/hashed-sharding/",
25
+ configServers: "https://www.mongodb.com/docs/manual/core/sharded-cluster-config-servers/",
26
+ mongos: "https://www.mongodb.com/docs/manual/core/sharded-cluster-query-router/"
27
+ };
28
+
29
+ function validatePositiveInteger(label) {
30
+ return (value) => {
31
+ if (!Number.isInteger(value) || value < 1) {
32
+ return `${label} must be an integer greater than 0.`;
33
+ }
34
+
35
+ return true;
36
+ };
37
+ }
38
+
39
+ function validatePort(value) {
40
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
41
+ return "Port must be an integer between 1 and 65535.";
42
+ }
43
+
44
+ return true;
45
+ }
46
+
47
+ function sleep(milliseconds) {
48
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
49
+ }
50
+
51
+ function printDocsLink(label, url) {
52
+ console.log(`Docs: ${label} -> ${url}`);
53
+ }
54
+
55
+ function normalizeClusterConfig(answers) {
56
+ return {
57
+ shardCount: answers.shardCount,
58
+ replicaSetMembers: answers.replicaSetMembers,
59
+ mongodbVersion: answers.mongodbVersion,
60
+ mongosPort: answers.mongosPort,
61
+ storagePath: path.resolve(process.cwd(), answers.storagePath),
62
+ configServerReplicaSet: "configReplSet",
63
+ configServerMembers: CONFIG_SERVER_MEMBERS
64
+ };
65
+ }
66
+
67
+ async function promptClusterOptions() {
68
+ console.log("\nMongoDB CLI Lab Setup\n");
69
+ printDocsLink("Sharding overview", DOCS.sharding);
70
+ printDocsLink("Replication overview", DOCS.replication);
71
+ printDocsLink("Config servers", DOCS.configServers);
72
+ printDocsLink("mongos router", DOCS.mongos);
73
+ console.log();
74
+
75
+ const answers = await inquirer.prompt([
76
+ {
77
+ type: "list",
78
+ name: "shardCount",
79
+ message: "How many shards do you want?",
80
+ choices: [
81
+ { name: "1 shard", value: "1" },
82
+ { name: "2 shards", value: "2" },
83
+ { name: "3 shards", value: "3" },
84
+ { name: "4 shards", value: "4" },
85
+ { name: "Custom", value: "custom" },
86
+ { name: "Back", value: "back" }
87
+ ],
88
+ default: "2"
89
+ },
90
+ {
91
+ type: "input",
92
+ name: "customShardCount",
93
+ message: "Enter the number of shards:",
94
+ when: (answers) => answers.shardCount === "custom",
95
+ validate: (value) =>
96
+ value.trim().toLowerCase() === "back" ||
97
+ validatePositiveInteger("Shard count")(Number(value))
98
+ },
99
+ {
100
+ type: "list",
101
+ name: "replicaSetMembers",
102
+ message: "How many replica set members per shard?",
103
+ choices: [
104
+ { name: "1 member", value: "1" },
105
+ { name: "3 members", value: "3" },
106
+ { name: "5 members", value: "5" },
107
+ { name: "Custom", value: "custom" },
108
+ { name: "Back", value: "back" }
109
+ ],
110
+ default: "3"
111
+ },
112
+ {
113
+ type: "input",
114
+ name: "customReplicaSetMembers",
115
+ message: "Enter replica set members per shard:",
116
+ when: (answers) => answers.replicaSetMembers === "custom",
117
+ validate: (value) =>
118
+ value.trim().toLowerCase() === "back" ||
119
+ validatePositiveInteger("Replica set members")(Number(value))
120
+ },
121
+ {
122
+ type: "list",
123
+ name: "mongodbVersion",
124
+ message: "Which MongoDB version should be used?",
125
+ choices: ["8.0", "7.0", "6.0", "5.0", "Custom", "Back"],
126
+ default: "7.0"
127
+ },
128
+ {
129
+ type: "input",
130
+ name: "customMongodbVersion",
131
+ message: "Enter the MongoDB Docker tag/version:",
132
+ when: (answers) => answers.mongodbVersion === "Custom",
133
+ default: "7.0",
134
+ validate: (value) =>
135
+ value.trim().toLowerCase() === "back" || value.trim() ? true : "Version cannot be empty."
136
+ },
137
+ {
138
+ type: "list",
139
+ name: "mongosPort",
140
+ message: "Which port should mongos expose?",
141
+ choices: [
142
+ { name: "27017", value: "27017" },
143
+ { name: "28000", value: "28000" },
144
+ { name: "30000", value: "30000" },
145
+ { name: "Custom", value: "custom" },
146
+ { name: "Back", value: "back" }
147
+ ],
148
+ default: "28000"
149
+ },
150
+ {
151
+ type: "input",
152
+ name: "customMongosPort",
153
+ message: "Enter the mongos port:",
154
+ when: (answers) => answers.mongosPort === "custom",
155
+ validate: (value) =>
156
+ value.trim().toLowerCase() === "back" || validatePort(Number(value))
157
+ },
158
+ {
159
+ type: "list",
160
+ name: "storagePath",
161
+ message: "Where should the cluster files be stored?",
162
+ choices: [
163
+ { name: `Current folder (${DEFAULT_STORAGE_PATH})`, value: DEFAULT_STORAGE_PATH },
164
+ { name: "/tmp/mongodb-cli-lab", value: "/tmp/mongodb-cli-lab" },
165
+ { name: "Custom", value: "custom" },
166
+ { name: "Back", value: "back" }
167
+ ],
168
+ default: DEFAULT_STORAGE_PATH
169
+ },
170
+ {
171
+ type: "input",
172
+ name: "customStoragePath",
173
+ message: "Enter the cluster storage path:",
174
+ when: (answers) => answers.storagePath === "custom",
175
+ default: DEFAULT_STORAGE_PATH,
176
+ validate: (value) =>
177
+ value.trim().toLowerCase() === "back" || value.trim() ? true : "Storage path cannot be empty."
178
+ }
179
+ ]);
180
+
181
+ if (
182
+ answers.shardCount === "back" ||
183
+ answers.replicaSetMembers === "back" ||
184
+ answers.mongodbVersion === "Back" ||
185
+ answers.mongosPort === "back" ||
186
+ answers.storagePath === "back" ||
187
+ answers.customShardCount?.trim?.().toLowerCase() === "back" ||
188
+ answers.customReplicaSetMembers?.trim?.().toLowerCase() === "back" ||
189
+ answers.customMongodbVersion?.trim?.().toLowerCase() === "back" ||
190
+ answers.customMongosPort?.trim?.().toLowerCase() === "back" ||
191
+ answers.customStoragePath?.trim?.().toLowerCase() === "back"
192
+ ) {
193
+ return null;
194
+ }
195
+
196
+ return normalizeClusterConfig({
197
+ shardCount: answers.shardCount === "custom" ? Number(answers.customShardCount) : Number(answers.shardCount),
198
+ replicaSetMembers:
199
+ answers.replicaSetMembers === "custom"
200
+ ? Number(answers.customReplicaSetMembers)
201
+ : Number(answers.replicaSetMembers),
202
+ mongodbVersion:
203
+ answers.mongodbVersion === "Custom"
204
+ ? answers.customMongodbVersion.trim()
205
+ : answers.mongodbVersion,
206
+ mongosPort:
207
+ answers.mongosPort === "custom" ? Number(answers.customMongosPort) : Number(answers.mongosPort),
208
+ storagePath:
209
+ answers.storagePath === "custom"
210
+ ? answers.customStoragePath.trim()
211
+ : answers.storagePath
212
+ });
213
+ }
214
+
215
+ function buildTopology(config) {
216
+ const configServers = Array.from({ length: config.configServerMembers }, (_, index) => ({
217
+ id: `cfg${index + 1}`,
218
+ serviceName: `cfg${index + 1}`,
219
+ replicaSet: config.configServerReplicaSet,
220
+ dbPath: path.join(config.storagePath, "configdb", `cfg${index + 1}`)
221
+ }));
222
+
223
+ const shards = Array.from({ length: config.shardCount }, (_, shardIndex) => {
224
+ const shardId = shardIndex + 1;
225
+ const replicaSet = `shardRS${shardId}`;
226
+ const members = Array.from({ length: config.replicaSetMembers }, (_, memberIndex) => ({
227
+ id: `shard${shardId}-${memberIndex + 1}`,
228
+ serviceName: `shard${shardId}-${memberIndex + 1}`,
229
+ replicaSet,
230
+ dbPath: path.join(config.storagePath, `shard${shardId}`, `member${memberIndex + 1}`)
231
+ }));
232
+
233
+ return {
234
+ id: `shard${shardId}`,
235
+ replicaSet,
236
+ members
237
+ };
238
+ });
239
+
240
+ return {
241
+ configServers,
242
+ shards,
243
+ mongos: {
244
+ serviceName: "mongos",
245
+ hostPort: config.mongosPort,
246
+ containerPort: INTERNAL_MONGO_PORT
247
+ }
248
+ };
249
+ }
250
+
251
+ function indent(level, value) {
252
+ return `${" ".repeat(level)}${value}`;
253
+ }
254
+
255
+ function yamlQuote(value) {
256
+ return `'${String(value).replaceAll("'", "''")}'`;
257
+ }
258
+
259
+ function serviceToYaml(service) {
260
+ const lines = [
261
+ indent(1, `${service.name}:`),
262
+ indent(2, `image: mongo:${service.imageTag}`),
263
+ indent(2, `container_name: ${service.containerName ?? service.name}`)
264
+ ];
265
+
266
+ if (service.dependsOn?.length) {
267
+ lines.push(indent(2, "depends_on:"));
268
+ for (const dependency of service.dependsOn) {
269
+ lines.push(indent(3, `- ${dependency}`));
270
+ }
271
+ }
272
+
273
+ lines.push(indent(2, "command:"));
274
+ for (const part of service.command) {
275
+ lines.push(indent(3, `- "${String(part).replaceAll('"', '\\"')}"`));
276
+ }
277
+
278
+ if (service.ports?.length) {
279
+ lines.push(indent(2, "ports:"));
280
+ for (const port of service.ports) {
281
+ lines.push(indent(3, `- ${yamlQuote(port)}`));
282
+ }
283
+ }
284
+
285
+ lines.push(indent(2, "volumes:"));
286
+ for (const volume of service.volumes) {
287
+ lines.push(indent(3, `- ${yamlQuote(volume)}`));
288
+ }
289
+
290
+ lines.push(indent(2, 'restart: "no"'));
291
+
292
+ return lines.join("\n");
293
+ }
294
+
295
+ function generateComposeFile(config, topology) {
296
+ const configServerConnection = topology.configServers
297
+ .map((member) => `${member.serviceName}:${INTERNAL_MONGO_PORT}`)
298
+ .join(",");
299
+
300
+ const services = [
301
+ ...topology.configServers.map((member) => ({
302
+ name: member.serviceName,
303
+ containerName: member.serviceName,
304
+ imageTag: config.mongodbVersion,
305
+ command: [
306
+ "mongod",
307
+ "--configsvr",
308
+ "--replSet",
309
+ member.replicaSet,
310
+ "--bind_ip_all",
311
+ "--port",
312
+ INTERNAL_MONGO_PORT,
313
+ "--dbpath",
314
+ "/data/db"
315
+ ],
316
+ volumes: [`${member.dbPath}:/data/db`]
317
+ })),
318
+ ...topology.shards.flatMap((shard) =>
319
+ shard.members.map((member) => ({
320
+ name: member.serviceName,
321
+ containerName: member.serviceName,
322
+ imageTag: config.mongodbVersion,
323
+ command: [
324
+ "mongod",
325
+ "--shardsvr",
326
+ "--replSet",
327
+ member.replicaSet,
328
+ "--bind_ip_all",
329
+ "--port",
330
+ INTERNAL_MONGO_PORT,
331
+ "--dbpath",
332
+ "/data/db"
333
+ ],
334
+ volumes: [`${member.dbPath}:/data/db`]
335
+ }))
336
+ ),
337
+ {
338
+ name: topology.mongos.serviceName,
339
+ containerName: topology.mongos.serviceName,
340
+ imageTag: config.mongodbVersion,
341
+ dependsOn: topology.configServers.map((member) => member.serviceName),
342
+ command: [
343
+ "mongos",
344
+ "--configdb",
345
+ `${config.configServerReplicaSet}/${configServerConnection}`,
346
+ "--bind_ip_all",
347
+ "--port",
348
+ INTERNAL_MONGO_PORT
349
+ ],
350
+ ports: [`${topology.mongos.hostPort}:${topology.mongos.containerPort}`],
351
+ volumes: [path.join(config.storagePath, "logs") + ":/var/log/mongodb"]
352
+ }
353
+ ];
354
+
355
+ return ["services:", ...services.map(serviceToYaml)].join("\n");
356
+ }
357
+
358
+ function buildReplicaSetConfig(replicaSet, members, options = {}) {
359
+ return {
360
+ _id: replicaSet,
361
+ ...(options.configsvr ? { configsvr: true } : {}),
362
+ members: members.map((member, index) => ({
363
+ _id: index,
364
+ host: `${member.serviceName}:${INTERNAL_MONGO_PORT}`
365
+ }))
366
+ };
367
+ }
368
+
369
+ function buildReplicaInitScript(replicaConfig) {
370
+ return `
371
+ const cfg = ${JSON.stringify(replicaConfig, null, 2)};
372
+ try {
373
+ const status = db.adminCommand({ replSetGetStatus: 1 });
374
+ if (status.ok === 1) {
375
+ print("Replica set already initialized: " + status.set);
376
+ quit(0);
377
+ }
378
+ } catch (error) {
379
+ if (error.code !== 94) {
380
+ throw error;
381
+ }
382
+ }
383
+
384
+ print("Initializing replica set " + cfg._id);
385
+ const initiateResult = rs.initiate(cfg);
386
+ printjson(initiateResult);
387
+
388
+ for (let attempt = 0; attempt < 120; attempt += 1) {
389
+ try {
390
+ const hello = (db.hello && db.hello()) || db.isMaster();
391
+ if (hello.isWritablePrimary || hello.ismaster === true) {
392
+ print("Primary ready for " + cfg._id);
393
+ quit(0);
394
+ }
395
+
396
+ const status = db.adminCommand({ replSetGetStatus: 1 });
397
+ if (status.ok === 1) {
398
+ const members = (status.members || []).map((member) => ({
399
+ name: member.name,
400
+ state: member.stateStr,
401
+ health: member.health
402
+ }));
403
+
404
+ if (attempt === 0 || attempt % 5 === 0) {
405
+ print(
406
+ "Waiting for primary in " +
407
+ cfg._id +
408
+ " (attempt " +
409
+ (attempt + 1) +
410
+ "/120): " +
411
+ JSON.stringify(members)
412
+ );
413
+ }
414
+ } else if (attempt === 0 || attempt % 5 === 0) {
415
+ print(
416
+ "Waiting for primary in " +
417
+ cfg._id +
418
+ " (attempt " +
419
+ (attempt + 1) +
420
+ "/120), status not ready yet"
421
+ );
422
+ }
423
+ } catch (error) {
424
+ if (attempt === 0 || attempt % 5 === 0) {
425
+ print(
426
+ "Waiting for primary in " +
427
+ cfg._id +
428
+ " (attempt " +
429
+ (attempt + 1) +
430
+ "/120), reason: " +
431
+ error.message
432
+ );
433
+ }
434
+ }
435
+
436
+ sleep(1000);
437
+ }
438
+
439
+ try {
440
+ const finalStatus = db.adminCommand({ replSetGetStatus: 1 });
441
+ print("Final replica set status for " + cfg._id + ":");
442
+ printjson(finalStatus);
443
+ } catch (error) {
444
+ print("Could not read final replica set status for " + cfg._id + ": " + error.message);
445
+ }
446
+
447
+ throw new Error("Timeout waiting for primary in " + cfg._id);
448
+ `.trim();
449
+ }
450
+
451
+ function buildAddShardsScript(topology) {
452
+ const shardConnections = topology.shards.map(
453
+ (shard) =>
454
+ `${shard.replicaSet}/${shard.members
455
+ .map((member) => `${member.serviceName}:${INTERNAL_MONGO_PORT}`)
456
+ .join(",")}`
457
+ );
458
+
459
+ return `
460
+ const shardConnections = ${JSON.stringify(shardConnections, null, 2)};
461
+
462
+ for (const connectionString of shardConnections) {
463
+ const shardName = connectionString.split("/")[0];
464
+ const current = db.adminCommand({ listShards: 1 });
465
+ if (current.ok !== 1) {
466
+ throw new Error("listShards failed: " + tojson(current));
467
+ }
468
+
469
+ const alreadyAdded = (current.shards || []).some((shard) => shard._id === shardName);
470
+ if (alreadyAdded) {
471
+ print("Shard already exists: " + connectionString);
472
+ continue;
473
+ }
474
+
475
+ const result = sh.addShard(connectionString);
476
+ if (result.ok !== 1) {
477
+ throw new Error("addShard failed: " + tojson(result));
478
+ }
479
+
480
+ print("Added shard: " + connectionString);
481
+ }
482
+
483
+ sh.status();
484
+ `.trim();
485
+ }
486
+
487
+ async function ensureDirectories(topology, storagePath) {
488
+ const directories = [
489
+ path.join(storagePath, "configdb"),
490
+ path.join(storagePath, "logs"),
491
+ ...topology.configServers.map((member) => member.dbPath),
492
+ ...topology.shards.flatMap((shard) => [
493
+ path.join(storagePath, shard.id),
494
+ ...shard.members.map((member) => member.dbPath)
495
+ ])
496
+ ];
497
+
498
+ await Promise.all(directories.map((directory) => fs.mkdir(directory, { recursive: true })));
499
+ }
500
+
501
+ function runCommand(command, args, options = {}) {
502
+ return execFileSync(command, args, {
503
+ stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
504
+ encoding: options.capture ? "utf8" : undefined
505
+ });
506
+ }
507
+
508
+ function detectDockerComposeCommand() {
509
+ const candidates = [
510
+ ["docker", ["compose"]],
511
+ ["docker-compose", []]
512
+ ];
513
+
514
+ for (const [command, baseArgs] of candidates) {
515
+ try {
516
+ runCommand(command, [...baseArgs, "version"], { capture: true });
517
+ return { command, baseArgs };
518
+ } catch {
519
+ // Try the next candidate.
520
+ }
521
+ }
522
+
523
+ return null;
524
+ }
525
+
526
+ function ensureDockerAvailable() {
527
+ try {
528
+ runCommand("docker", ["--version"]);
529
+ } catch {
530
+ throw new Error("Docker is required and was not found in PATH.");
531
+ }
532
+
533
+ dockerComposeCommand = detectDockerComposeCommand();
534
+ if (!dockerComposeCommand) {
535
+ throw new Error(
536
+ "Docker Compose is required and was not found. Install a Docker version that provides 'docker compose' or 'docker-compose'."
537
+ );
538
+ }
539
+ }
540
+
541
+ function composeArgs(state, args = []) {
542
+ if (!dockerComposeCommand) {
543
+ throw new Error("Docker Compose command is not initialized.");
544
+ }
545
+
546
+ return [
547
+ ...dockerComposeCommand.baseArgs,
548
+ "-f",
549
+ state.composeFile,
550
+ "-p",
551
+ state.projectName,
552
+ ...args
553
+ ];
554
+ }
555
+
556
+ function runCompose(state, args = [], options = {}) {
557
+ if (!dockerComposeCommand) {
558
+ throw new Error("Docker Compose command is not initialized.");
559
+ }
560
+
561
+ return runCommand(dockerComposeCommand.command, composeArgs(state, args), options);
562
+ }
563
+
564
+ async function writeClusterFiles(config, topology) {
565
+ await ensureDirectories(topology, config.storagePath);
566
+
567
+ const composeFile = path.join(config.storagePath, "docker-compose.yml");
568
+ const clusterConfigFile = path.join(config.storagePath, "cluster-config.json");
569
+ const topologyFile = path.join(config.storagePath, "topology.json");
570
+
571
+ await fs.writeFile(composeFile, generateComposeFile(config, topology), "utf8");
572
+ await fs.writeFile(clusterConfigFile, JSON.stringify(config, null, 2), "utf8");
573
+ await fs.writeFile(topologyFile, JSON.stringify(topology, null, 2), "utf8");
574
+
575
+ return {
576
+ composeFile,
577
+ clusterConfigFile,
578
+ topologyFile
579
+ };
580
+ }
581
+
582
+ function getStateFilePath(storagePath) {
583
+ return path.join(storagePath, STATE_FILE_NAME);
584
+ }
585
+
586
+ function getGlobalStateFilePath() {
587
+ return path.join(GLOBAL_STATE_DIR, STATE_FILE_NAME);
588
+ }
589
+
590
+ async function saveState(state) {
591
+ await fs.mkdir(GLOBAL_STATE_DIR, { recursive: true });
592
+ await fs.writeFile(getGlobalStateFilePath(), JSON.stringify(state, null, 2), "utf8");
593
+ await fs.writeFile(getStateFilePath(state.config.storagePath), JSON.stringify(state, null, 2), "utf8");
594
+ }
595
+
596
+ async function tryReadStateFile(stateFilePath) {
597
+ if (!existsSync(stateFilePath)) {
598
+ return null;
599
+ }
600
+
601
+ const raw = await fs.readFile(stateFilePath, "utf8");
602
+ return JSON.parse(raw);
603
+ }
604
+
605
+ function tryRunCommand(command, args, options = {}) {
606
+ try {
607
+ return runCommand(command, args, options);
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+
613
+ function listProjectContainerIds(serviceName = null) {
614
+ const args = [
615
+ "ps",
616
+ "-a",
617
+ "--filter",
618
+ `label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}`
619
+ ];
620
+
621
+ if (serviceName) {
622
+ args.push("--filter", `label=com.docker.compose.service=${serviceName}`);
623
+ }
624
+
625
+ args.push("--format", "{{.ID}}");
626
+
627
+ const output = tryRunCommand("docker", args, { capture: true })?.trim() ?? "";
628
+ if (!output) {
629
+ return [];
630
+ }
631
+
632
+ return output
633
+ .split("\n")
634
+ .map((line) => line.trim())
635
+ .filter(Boolean);
636
+ }
637
+
638
+ function inferStoragePathFromContainerInspect(containerDetails) {
639
+ const mounts = Array.isArray(containerDetails?.Mounts) ? containerDetails.Mounts : [];
640
+
641
+ for (const mount of mounts) {
642
+ if (typeof mount?.Source !== "string") {
643
+ continue;
644
+ }
645
+
646
+ if (mount.Source.endsWith(`${path.sep}logs`)) {
647
+ return path.dirname(mount.Source);
648
+ }
649
+
650
+ if (mount.Source.endsWith(`${path.sep}configdb${path.sep}cfg1`)) {
651
+ return path.dirname(path.dirname(mount.Source));
652
+ }
653
+ }
654
+
655
+ return null;
656
+ }
657
+
658
+ async function loadStateFromStoragePath(storagePath) {
659
+ const clusterConfigFile = path.join(storagePath, "cluster-config.json");
660
+ const topologyFile = path.join(storagePath, "topology.json");
661
+ const composeFile = path.join(storagePath, "docker-compose.yml");
662
+
663
+ if (!existsSync(clusterConfigFile) || !existsSync(topologyFile) || !existsSync(composeFile)) {
664
+ return null;
665
+ }
666
+
667
+ const [configRaw, topologyRaw] = await Promise.all([
668
+ fs.readFile(clusterConfigFile, "utf8"),
669
+ fs.readFile(topologyFile, "utf8")
670
+ ]);
671
+
672
+ return {
673
+ projectName: sanitizeProjectName(DEFAULT_PROJECT_NAME),
674
+ config: JSON.parse(configRaw),
675
+ topology: JSON.parse(topologyRaw),
676
+ composeFile,
677
+ clusterConfigFile,
678
+ topologyFile
679
+ };
680
+ }
681
+
682
+ async function discoverStateFromDocker() {
683
+ const containerIds = [
684
+ ...listProjectContainerIds("mongos"),
685
+ ...listProjectContainerIds()
686
+ ];
687
+
688
+ if (!containerIds.length) {
689
+ return null;
690
+ }
691
+
692
+ const inspectOutput = tryRunCommand("docker", ["inspect", containerIds[0]], { capture: true });
693
+ if (!inspectOutput) {
694
+ return null;
695
+ }
696
+
697
+ let details;
698
+ try {
699
+ details = JSON.parse(inspectOutput);
700
+ } catch {
701
+ return null;
702
+ }
703
+
704
+ const containerDetails = Array.isArray(details) ? details[0] : null;
705
+ const storagePath = inferStoragePathFromContainerInspect(containerDetails);
706
+ if (!storagePath) {
707
+ return null;
708
+ }
709
+
710
+ const discoveredState = await loadStateFromStoragePath(storagePath);
711
+ if (!discoveredState) {
712
+ return null;
713
+ }
714
+
715
+ await saveState(discoveredState);
716
+ return discoveredState;
717
+ }
718
+
719
+ async function loadActiveClusterState() {
720
+ return discoverStateFromDocker();
721
+ }
722
+
723
+ async function loadState() {
724
+ const activeState = await loadActiveClusterState();
725
+ if (activeState) {
726
+ return activeState;
727
+ }
728
+
729
+ const globalState = await tryReadStateFile(getGlobalStateFilePath());
730
+ if (globalState) {
731
+ return globalState;
732
+ }
733
+
734
+ const legacyStatePath = getStateFilePath(path.resolve(process.cwd(), DEFAULT_STORAGE_PATH));
735
+ const legacyState = await tryReadStateFile(legacyStatePath);
736
+ if (!legacyState) {
737
+ return null;
738
+ }
739
+
740
+ await saveState(legacyState);
741
+ return legacyState;
742
+ }
743
+
744
+ async function deleteState(storagePath) {
745
+ const globalStateFilePath = getGlobalStateFilePath();
746
+ if (existsSync(globalStateFilePath)) {
747
+ await fs.unlink(globalStateFilePath);
748
+ }
749
+
750
+ const stateFilePath = getStateFilePath(storagePath);
751
+ if (existsSync(stateFilePath)) {
752
+ await fs.unlink(stateFilePath);
753
+ }
754
+ }
755
+
756
+ function countExpectedNodes(state) {
757
+ return (
758
+ state.topology.configServers.length +
759
+ state.topology.shards.reduce((total, shard) => total + shard.members.length, 0) +
760
+ 1
761
+ );
762
+ }
763
+
764
+ function formatPorts(publishers = []) {
765
+ const ports = publishers
766
+ .map((publisher) => {
767
+ if (!publisher.PublishedPort) {
768
+ return null;
769
+ }
770
+
771
+ return `${publisher.PublishedPort}->${publisher.TargetPort}/${publisher.Protocol}`;
772
+ })
773
+ .filter(Boolean);
774
+
775
+ return ports.length ? ports.join(", ") : "-";
776
+ }
777
+
778
+ function summarizeServiceRole(serviceName) {
779
+ if (serviceName.startsWith("cfg")) {
780
+ return "configsvr";
781
+ }
782
+
783
+ if (serviceName === "mongos") {
784
+ return "mongos";
785
+ }
786
+
787
+ return "shardsvr";
788
+ }
789
+
790
+ function buildDisplayName(serviceName, port = INTERNAL_MONGO_PORT) {
791
+ if (serviceName.startsWith("cfg")) {
792
+ return `cfg-${port}`;
793
+ }
794
+
795
+ if (serviceName === "mongos") {
796
+ return `mongos-${port}`;
797
+ }
798
+
799
+ return `shard-${port}`;
800
+ }
801
+
802
+ function getComposeContainers(state) {
803
+ try {
804
+ const output = runCompose(state, ["ps", "--format", "json"], { capture: true });
805
+ const trimmedOutput = output.trim();
806
+
807
+ if (!trimmedOutput) {
808
+ return [];
809
+ }
810
+
811
+ if (trimmedOutput.startsWith("[")) {
812
+ const parsed = JSON.parse(trimmedOutput);
813
+ return Array.isArray(parsed) ? parsed : [];
814
+ }
815
+
816
+ return trimmedOutput
817
+ .split("\n")
818
+ .map((line) => line.trim())
819
+ .filter(Boolean)
820
+ .map((line) => JSON.parse(line));
821
+ } catch {
822
+ return [];
823
+ }
824
+ }
825
+
826
+ function buildContainerMap(containers) {
827
+ return new Map(containers.map((container) => [container.Service, container]));
828
+ }
829
+
830
+ function containerStateLabel(container) {
831
+ return container?.State ?? "not created";
832
+ }
833
+
834
+ function containerPortLabel(container, fallbackPort = INTERNAL_MONGO_PORT) {
835
+ const ports = formatPorts(container?.Publishers);
836
+ if (ports !== "-") {
837
+ return ports;
838
+ }
839
+
840
+ return `internal:${fallbackPort}`;
841
+ }
842
+
843
+ function printClusterSummary(state, containers) {
844
+ const runningCount = containers.filter((container) => container.State === "running").length;
845
+ const expectedNodes = countExpectedNodes(state);
846
+
847
+ console.log("\nCluster summary\n");
848
+ console.log(`Project: ${state.projectName}`);
849
+ console.log(`Storage: ${state.config.storagePath}`);
850
+ console.log(`MongoDB version: ${state.config.mongodbVersion}`);
851
+ console.log(`Shards: ${state.config.shardCount}`);
852
+ console.log(`Replica set members per shard: ${state.config.replicaSetMembers}`);
853
+ console.log(`Nodes running: ${runningCount}/${expectedNodes}`);
854
+ console.log(`mongos: mongodb://localhost:${state.config.mongosPort}\n`);
855
+ }
856
+
857
+ function printContainersTable(containers) {
858
+ if (!containers.length) {
859
+ console.log("No containers found for this cluster.\n");
860
+ return;
861
+ }
862
+
863
+ const rows = containers.map((container) => ({
864
+ service: buildDisplayName(container.Service),
865
+ container: container.Service,
866
+ role: summarizeServiceRole(container.Service),
867
+ state: container.State,
868
+ ports: formatPorts(container.Publishers)
869
+ }));
870
+
871
+ console.table(rows);
872
+ }
873
+
874
+ function printTopologyDetails(state, containers) {
875
+ const containerMap = buildContainerMap(containers);
876
+
877
+ console.log("Topology\n");
878
+
879
+ console.log(`Config server replica set: ${state.config.configServerReplicaSet}`);
880
+ console.log(`Members: ${state.config.configServerMembers}`);
881
+ for (const member of state.topology.configServers) {
882
+ const container = containerMap.get(member.serviceName);
883
+ console.log(
884
+ `- ${buildDisplayName(member.serviceName)} (${member.serviceName}) | state: ${containerStateLabel(container)} | port: ${containerPortLabel(container)}`
885
+ );
886
+ }
887
+
888
+ console.log(`\nRouter`);
889
+ const mongosContainer = containerMap.get(state.topology.mongos.serviceName);
890
+ console.log(
891
+ `- ${buildDisplayName(state.topology.mongos.serviceName)} (${state.topology.mongos.serviceName}) | state: ${containerStateLabel(mongosContainer)} | port: ${containerPortLabel(mongosContainer)}`
892
+ );
893
+
894
+ console.log(`\nShards`);
895
+ for (const [index, shard] of state.topology.shards.entries()) {
896
+ console.log(`- Shard ${index + 1}: ${shard.replicaSet}`);
897
+ console.log(` Members: ${shard.members.length}`);
898
+ for (const member of shard.members) {
899
+ const container = containerMap.get(member.serviceName);
900
+ console.log(
901
+ ` - ${buildDisplayName(member.serviceName)} (${member.serviceName}) | state: ${containerStateLabel(container)} | port: ${containerPortLabel(container)}`
902
+ );
903
+ }
904
+ }
905
+
906
+ console.log();
907
+ }
908
+
909
+ function printReplicaSetHealth(state, containers) {
910
+ const containerMap = buildContainerMap(containers);
911
+ const configRunning = state.topology.configServers.filter(
912
+ (member) => containerMap.get(member.serviceName)?.State === "running"
913
+ ).length;
914
+
915
+ console.log("Replica set health\n");
916
+ console.log(
917
+ `- ${state.config.configServerReplicaSet} | running members: ${configRunning}/${state.topology.configServers.length}`
918
+ );
919
+
920
+ for (const shard of state.topology.shards) {
921
+ const runningMembers = shard.members.filter(
922
+ (member) => containerMap.get(member.serviceName)?.State === "running"
923
+ ).length;
924
+
925
+ const label =
926
+ runningMembers === shard.members.length
927
+ ? "healthy"
928
+ : runningMembers === 0
929
+ ? "down"
930
+ : "degraded";
931
+
932
+ console.log(
933
+ `- ${shard.replicaSet} | ${label} | running members: ${runningMembers}/${shard.members.length}`
934
+ );
935
+ }
936
+
937
+ const mongosState = containerMap.get(state.topology.mongos.serviceName)?.State ?? "not created";
938
+ console.log(`- mongos | state: ${mongosState}\n`);
939
+ }
940
+
941
+ function printTopologyDiagram(state) {
942
+ console.log("Cluster structure\n");
943
+ console.log(" +---------------------------+");
944
+ console.log(` | mongos |`);
945
+ console.log(` | localhost:${state.config.mongosPort}${" ".repeat(Math.max(0, 12 - String(state.config.mongosPort).length))}|`);
946
+ console.log(" +-------------+-------------+");
947
+ console.log(" |");
948
+ console.log(" +-------------v-------------+");
949
+ console.log(` | ${state.config.configServerReplicaSet.padEnd(27, " ")}|`);
950
+ console.log(
951
+ ` | ${state.topology.configServers.map((member) => member.serviceName).join(" ").padEnd(27, " ")}|`
952
+ );
953
+ console.log(" +-------------+-------------+");
954
+ console.log(" |");
955
+
956
+ for (const shard of state.topology.shards) {
957
+ console.log(" +------v------+");
958
+ console.log(` | ${shard.replicaSet.padEnd(11, " ")}|`);
959
+ console.log(
960
+ ` | ${`${shard.members.length} members`.padEnd(11, " ")}|`
961
+ );
962
+ console.log(" +-------------+");
963
+ }
964
+
965
+ console.log();
966
+ }
967
+
968
+ function printStep(stepNumber, totalSteps, title, description) {
969
+ console.log(`\n=== Step ${stepNumber}/${totalSteps}: ${title} ===`);
970
+ if (description) {
971
+ console.log(description);
972
+ }
973
+ }
974
+
975
+ function waitForMongo(state, serviceName) {
976
+ console.log(`Waiting for MongoDB service '${serviceName}'...`);
977
+
978
+ for (let attempt = 0; attempt < 120; attempt += 1) {
979
+ try {
980
+ runCompose(state, [
981
+ "exec",
982
+ "-T",
983
+ serviceName,
984
+ "mongosh",
985
+ "--quiet",
986
+ "--eval",
987
+ "db.adminCommand({ ping: 1 })"
988
+ ]);
989
+ console.log(`MongoDB service '${serviceName}' is ready.`);
990
+ return;
991
+ } catch {
992
+ if (attempt > 0 && attempt % 10 === 0) {
993
+ console.log(`Still waiting for '${serviceName}' (${attempt}s elapsed)...`);
994
+ }
995
+ sleep(1000);
996
+ }
997
+ }
998
+
999
+ throw new Error(`Timed out waiting for MongoDB service '${serviceName}'.`);
1000
+ }
1001
+
1002
+ function waitForServices(state, serviceNames) {
1003
+ for (const serviceName of serviceNames) {
1004
+ waitForMongo(state, serviceName);
1005
+ }
1006
+ }
1007
+
1008
+ function runMongoScript(state, serviceName, script) {
1009
+ runCompose(state, ["exec", "-T", serviceName, "mongosh", "--quiet", "--eval", script]);
1010
+ }
1011
+
1012
+ function runMongoScriptCapture(state, serviceName, script) {
1013
+ return runCompose(
1014
+ state,
1015
+ ["exec", "-T", serviceName, "mongosh", "--quiet", "--eval", script],
1016
+ { capture: true }
1017
+ );
1018
+ }
1019
+
1020
+ function runMongoJson(state, serviceName, script) {
1021
+ const marker = "__MONGO_SHARDING_LAB_JSON__";
1022
+ const output = runMongoScriptCapture(
1023
+ state,
1024
+ serviceName,
1025
+ `${script}\nprint("${marker}" + JSON.stringify(result));`
1026
+ );
1027
+
1028
+ const line = output
1029
+ .split("\n")
1030
+ .map((entry) => entry.trim())
1031
+ .find((entry) => entry.startsWith(marker));
1032
+
1033
+ if (!line) {
1034
+ throw new Error("Could not parse MongoDB command output.");
1035
+ }
1036
+
1037
+ return JSON.parse(line.slice(marker.length));
1038
+ }
1039
+
1040
+ function getCollectionDocumentCount(state, databaseName, collectionName) {
1041
+ return runMongoJson(
1042
+ state,
1043
+ state.topology.mongos.serviceName,
1044
+ `
1045
+ const databaseName = ${JSON.stringify(databaseName)};
1046
+ const collectionName = ${JSON.stringify(collectionName)};
1047
+ const collection = db.getSiblingDB(databaseName).getCollection(collectionName);
1048
+
1049
+ let count = 0;
1050
+ try {
1051
+ count = collection.countDocuments({});
1052
+ } catch (error) {
1053
+ if (error.codeName !== "NamespaceNotFound" && !String(error.message || "").includes("ns does not exist")) {
1054
+ throw error;
1055
+ }
1056
+ }
1057
+
1058
+ const result = { count };
1059
+ `.trim()
1060
+ ).count;
1061
+ }
1062
+
1063
+ function getClusterOverview(state) {
1064
+ return runMongoJson(
1065
+ state,
1066
+ state.topology.mongos.serviceName,
1067
+ `
1068
+ const userDatabases = db
1069
+ .adminCommand({ listDatabases: 1 })
1070
+ .databases
1071
+ .map((database) => database.name)
1072
+ .filter((name) => !["admin", "config", "local"].includes(name));
1073
+
1074
+ const shardedCollections = new Map(
1075
+ db
1076
+ .getSiblingDB("config")
1077
+ .collections
1078
+ .find(
1079
+ {
1080
+ dropped: { $ne: true },
1081
+ key: { $exists: true }
1082
+ },
1083
+ { _id: 1, key: 1 }
1084
+ )
1085
+ .toArray()
1086
+ .map((collection) => [collection._id, collection.key])
1087
+ );
1088
+
1089
+ const collections = [];
1090
+ for (const databaseName of userDatabases) {
1091
+ const infos = db
1092
+ .getSiblingDB(databaseName)
1093
+ .getCollectionInfos({}, true)
1094
+ .filter((info) => !info.name.startsWith("system."));
1095
+
1096
+ for (const info of infos) {
1097
+ const namespace = databaseName + "." + info.name;
1098
+ collections.push({
1099
+ db: databaseName,
1100
+ name: info.name,
1101
+ namespace,
1102
+ sharded: shardedCollections.has(namespace),
1103
+ shardKey: shardedCollections.get(namespace) || null
1104
+ });
1105
+ }
1106
+ }
1107
+
1108
+ const result = {
1109
+ databases: userDatabases,
1110
+ collections
1111
+ };
1112
+ `.trim()
1113
+ );
1114
+ }
1115
+
1116
+ function printCollectionOverview(overview) {
1117
+ if (!overview.collections.length) {
1118
+ console.log("\nNo user collections found in the cluster.\n");
1119
+ return;
1120
+ }
1121
+
1122
+ console.log("\nCollections\n");
1123
+ for (const collection of overview.collections) {
1124
+ const shardLabel = collection.sharded
1125
+ ? `sharded by ${JSON.stringify(collection.shardKey)}`
1126
+ : "not sharded";
1127
+ console.log(`- ${collection.namespace} | ${shardLabel}`);
1128
+ }
1129
+ console.log();
1130
+ }
1131
+
1132
+ function printBooksDemoIntroduction() {
1133
+ console.log("\nGuided demo: library.books\n");
1134
+ printDocsLink("Shard keys", DOCS.shardKey);
1135
+ printDocsLink("Hashed sharding", DOCS.hashedSharding);
1136
+ console.log();
1137
+ console.log("Sample document:");
1138
+ console.log(
1139
+ JSON.stringify(
1140
+ {
1141
+ title: "Clean Code",
1142
+ author: "Robert C. Martin",
1143
+ year: 2008,
1144
+ genre: "software",
1145
+ pages: 464,
1146
+ isbn: "9780132350884"
1147
+ },
1148
+ null,
1149
+ 2
1150
+ )
1151
+ );
1152
+ console.log("\nThis demo creates a 'library.books' collection and shards it by a chosen field.\n");
1153
+ console.log("After sharding and inserts, the CLI will show how documents were distributed across shards.\n");
1154
+ }
1155
+
1156
+ function ensureMongosRunning(state) {
1157
+ const containers = getComposeContainers(state);
1158
+ const mongos = containers.find((container) => container.Service === state.topology.mongos.serviceName);
1159
+
1160
+ if (!mongos || mongos.State !== "running") {
1161
+ throw new Error("mongos is not running. Start the cluster before managing sharded collections.");
1162
+ }
1163
+ }
1164
+
1165
+ async function promptCollectionTarget(overview) {
1166
+ const { databaseAction } = await inquirer.prompt([
1167
+ {
1168
+ type: "list",
1169
+ name: "databaseAction",
1170
+ message: "Step 1: Choose a database for this sharding exercise",
1171
+ choices: [
1172
+ ...overview.databases.map((database) => ({
1173
+ name: database,
1174
+ value: { mode: "existing", database }
1175
+ })),
1176
+ { name: "Create new database", value: { mode: "new" } },
1177
+ { name: "Back", value: { mode: "back" } }
1178
+ ]
1179
+ }
1180
+ ]);
1181
+
1182
+ if (databaseAction.mode === "back") {
1183
+ return null;
1184
+ }
1185
+
1186
+ let databaseName = databaseAction.database;
1187
+ if (databaseAction.mode === "new") {
1188
+ const answer = await inquirer.prompt([
1189
+ {
1190
+ type: "input",
1191
+ name: "databaseName",
1192
+ message: "Database name:",
1193
+ validate: (value) =>
1194
+ value.trim().toLowerCase() === "back" || value.trim()
1195
+ ? true
1196
+ : "Database name cannot be empty."
1197
+ }
1198
+ ]);
1199
+
1200
+ if (answer.databaseName.trim().toLowerCase() === "back") {
1201
+ return null;
1202
+ }
1203
+
1204
+ databaseName = answer.databaseName.trim();
1205
+ }
1206
+
1207
+ const collectionsInDatabase = overview.collections.filter(
1208
+ (collection) => collection.db === databaseName
1209
+ );
1210
+
1211
+ const { collectionAction } = await inquirer.prompt([
1212
+ {
1213
+ type: "list",
1214
+ name: "collectionAction",
1215
+ message: "Step 2: Choose a collection inside that database",
1216
+ choices: [
1217
+ ...collectionsInDatabase.map((collection) => ({
1218
+ name: `${collection.name}${collection.sharded ? " (already sharded)" : ""}`,
1219
+ value: { mode: "existing", collection }
1220
+ })),
1221
+ { name: "Create new collection", value: { mode: "new" } },
1222
+ { name: "Back", value: { mode: "back" } }
1223
+ ]
1224
+ }
1225
+ ]);
1226
+
1227
+ if (collectionAction.mode === "back") {
1228
+ return null;
1229
+ }
1230
+
1231
+ let collectionName = collectionAction.collection?.name;
1232
+ let alreadySharded = collectionAction.collection?.sharded ?? false;
1233
+ if (collectionAction.mode === "new") {
1234
+ const answer = await inquirer.prompt([
1235
+ {
1236
+ type: "input",
1237
+ name: "collectionName",
1238
+ message: "Collection name:",
1239
+ validate: (value) =>
1240
+ value.trim().toLowerCase() === "back" || value.trim()
1241
+ ? true
1242
+ : "Collection name cannot be empty."
1243
+ }
1244
+ ]);
1245
+
1246
+ if (answer.collectionName.trim().toLowerCase() === "back") {
1247
+ return null;
1248
+ }
1249
+
1250
+ collectionName = answer.collectionName.trim();
1251
+ alreadySharded = false;
1252
+ }
1253
+
1254
+ return {
1255
+ databaseName,
1256
+ collectionName,
1257
+ alreadySharded,
1258
+ currentShardKey: collectionAction.collection?.shardKey ?? null
1259
+ };
1260
+ }
1261
+
1262
+ function shardCollection(state, options) {
1263
+ const {
1264
+ databaseName,
1265
+ collectionName,
1266
+ shardKeyField,
1267
+ shardKeyMode = "range",
1268
+ documents,
1269
+ resetCollection = false,
1270
+ skipInsert = false
1271
+ } = options;
1272
+
1273
+ return runMongoJson(
1274
+ state,
1275
+ state.topology.mongos.serviceName,
1276
+ `
1277
+ const databaseName = ${JSON.stringify(databaseName)};
1278
+ const collectionName = ${JSON.stringify(collectionName)};
1279
+ const shardKeyField = ${JSON.stringify(shardKeyField)};
1280
+ const shardKeyMode = ${JSON.stringify(shardKeyMode)};
1281
+ const documents = ${JSON.stringify(documents)};
1282
+ const resetCollection = ${JSON.stringify(resetCollection)};
1283
+ const skipInsert = ${JSON.stringify(skipInsert)};
1284
+ const namespace = databaseName + "." + collectionName;
1285
+ const database = db.getSiblingDB(databaseName);
1286
+ const shardKey = { [shardKeyField]: shardKeyMode === "hashed" ? "hashed" : 1 };
1287
+
1288
+ if (resetCollection) {
1289
+ try {
1290
+ database.getCollection(collectionName).drop();
1291
+ } catch (error) {
1292
+ if (error.codeName !== "NamespaceNotFound") {
1293
+ throw error;
1294
+ }
1295
+ }
1296
+ }
1297
+
1298
+ try {
1299
+ database.createCollection(collectionName);
1300
+ } catch (error) {
1301
+ if (error.codeName !== "NamespaceExists") {
1302
+ throw error;
1303
+ }
1304
+ }
1305
+
1306
+ const enableResult = sh.enableSharding(databaseName);
1307
+ let shardResult;
1308
+ let actionTaken;
1309
+ const existing = db.getSiblingDB("config").collections.findOne({ _id: namespace, dropped: { $ne: true } });
1310
+ if (existing && existing.key) {
1311
+ if (JSON.stringify(existing.key) === JSON.stringify(shardKey)) {
1312
+ shardResult = { ok: 1, note: "already sharded with requested key", key: existing.key };
1313
+ actionTaken = "reuse-existing-shard-key";
1314
+ } else {
1315
+ shardResult = db.adminCommand({
1316
+ reshardCollection: namespace,
1317
+ key: shardKey
1318
+ });
1319
+ actionTaken = "reshard-collection";
1320
+ }
1321
+ } else {
1322
+ shardResult = sh.shardCollection(namespace, shardKey);
1323
+ actionTaken = "initial-shard-collection";
1324
+ }
1325
+
1326
+ let inserted = 0;
1327
+ if (!skipInsert && documents.length > 0) {
1328
+ const insertResult = database.getCollection(collectionName).insertMany(documents, { ordered: false });
1329
+ inserted = insertResult.insertedIds ? Object.keys(insertResult.insertedIds).length : documents.length;
1330
+ }
1331
+
1332
+ const result = {
1333
+ databaseName,
1334
+ collectionName,
1335
+ namespace,
1336
+ shardKey,
1337
+ previousShardKey: existing?.key || null,
1338
+ actionTaken,
1339
+ enableResult,
1340
+ shardResult,
1341
+ inserted
1342
+ };
1343
+ `.trim()
1344
+ );
1345
+ }
1346
+
1347
+ function printInsertPlan(label, insertCount) {
1348
+ if (insertCount <= 0) {
1349
+ console.log(`No demo documents will be inserted for ${label}.\n`);
1350
+ return;
1351
+ }
1352
+
1353
+ console.log(`Preparing to generate and insert ${insertCount} documents for ${label}...`);
1354
+ if (insertCount >= 10000) {
1355
+ console.log("This may take a while depending on your machine and Docker performance.");
1356
+ }
1357
+ console.log();
1358
+ }
1359
+
1360
+ function formatShardKey(field, mode = "range") {
1361
+ return JSON.stringify({ [field]: mode === "hashed" ? "hashed" : 1 });
1362
+ }
1363
+
1364
+ function getRecommendedBatchSize(insertCount) {
1365
+ if (insertCount >= 1000000) {
1366
+ return 50000;
1367
+ }
1368
+
1369
+ if (insertCount >= 100000) {
1370
+ return 20000;
1371
+ }
1372
+
1373
+ if (insertCount >= 10000) {
1374
+ return 10000;
1375
+ }
1376
+
1377
+ return 5000;
1378
+ }
1379
+
1380
+ function buildBooksBatchDocuments(insertCount, startIndex = 0) {
1381
+ const seedBooks = buildBooksDemoDocuments();
1382
+ const documents = [];
1383
+
1384
+ for (let relativeIndex = 0; relativeIndex < insertCount; relativeIndex += 1) {
1385
+ const index = startIndex + relativeIndex;
1386
+ const base = seedBooks[index % seedBooks.length];
1387
+ documents.push({
1388
+ _id: index + 1,
1389
+ title: `${base.title} #${index + 1}`,
1390
+ author: base.author,
1391
+ year: base.year + (index % 5),
1392
+ genre: base.genre,
1393
+ pages: base.pages + (index % 20),
1394
+ isbn: `${base.isbn}-${index + 1}`,
1395
+ copyNumber: index + 1
1396
+ });
1397
+ }
1398
+
1399
+ return documents;
1400
+ }
1401
+
1402
+ function insertDocumentsInBatches(state, options) {
1403
+ const {
1404
+ databaseName,
1405
+ collectionName,
1406
+ shardKeyField,
1407
+ insertCount,
1408
+ startIndex = 0,
1409
+ seedMode,
1410
+ batchSize = getRecommendedBatchSize(insertCount)
1411
+ } = options;
1412
+
1413
+ let inserted = 0;
1414
+ console.log(`Using batch size: ${batchSize}`);
1415
+
1416
+ for (let offset = 0; offset < insertCount; offset += batchSize) {
1417
+ const currentBatchSize = Math.min(batchSize, insertCount - offset);
1418
+ const batchStartIndex = startIndex + offset;
1419
+ const batchResult = runMongoJson(
1420
+ state,
1421
+ state.topology.mongos.serviceName,
1422
+ `
1423
+ const databaseName = ${JSON.stringify(databaseName)};
1424
+ const collectionName = ${JSON.stringify(collectionName)};
1425
+ const shardKeyField = ${JSON.stringify(shardKeyField)};
1426
+ const seedMode = ${JSON.stringify(seedMode)};
1427
+ const currentBatchSize = ${currentBatchSize};
1428
+ const batchStartIndex = ${batchStartIndex};
1429
+ const database = db.getSiblingDB(databaseName);
1430
+
1431
+ function buildSampleDocuments(fieldName, totalCount, initialOffset) {
1432
+ const generated = [];
1433
+ for (let relativeIndex = 1; relativeIndex <= totalCount; relativeIndex += 1) {
1434
+ const index = initialOffset + relativeIndex;
1435
+ generated.push({
1436
+ [fieldName]: fieldName === "_id" ? index : fieldName + "-" + index,
1437
+ sampleIndex: index,
1438
+ source: "mongodb-cli-lab",
1439
+ createdAt: new Date()
1440
+ });
1441
+ }
1442
+ return generated;
1443
+ }
1444
+
1445
+ function buildBooksDataset(totalCount, initialOffset) {
1446
+ const seedBooks = [
1447
+ { title: "Clean Code", author: "Robert C. Martin", year: 2008, genre: "software", pages: 464, isbn: "9780132350884" },
1448
+ { title: "Designing Data-Intensive Applications", author: "Martin Kleppmann", year: 2017, genre: "databases", pages: 616, isbn: "9781449373320" },
1449
+ { title: "Refactoring", author: "Martin Fowler", year: 1999, genre: "software", pages: 448, isbn: "9780201485677" },
1450
+ { title: "MongoDB: The Definitive Guide", author: "Kristina Chodorow", year: 2013, genre: "databases", pages: 432, isbn: "9781449344689" },
1451
+ { title: "Patterns of Enterprise Application Architecture", author: "Martin Fowler", year: 2002, genre: "architecture", pages: 560, isbn: "9780321127426" },
1452
+ { title: "Release It!", author: "Michael T. Nygard", year: 2007, genre: "operations", pages: 368, isbn: "9780978739218" },
1453
+ { title: "The Pragmatic Programmer", author: "Andrew Hunt", year: 1999, genre: "software", pages: 352, isbn: "9780201616224" },
1454
+ { title: "Building Microservices", author: "Sam Newman", year: 2015, genre: "architecture", pages: 280, isbn: "9781491950357" },
1455
+ { title: "Effective Java", author: "Joshua Bloch", year: 2018, genre: "software", pages: 416, isbn: "9780134685991" },
1456
+ { title: "Site Reliability Engineering", author: "Betsy Beyer", year: 2016, genre: "operations", pages: 552, isbn: "9781491929124" }
1457
+ ];
1458
+
1459
+ const generated = [];
1460
+ for (let relativeIndex = 0; relativeIndex < totalCount; relativeIndex += 1) {
1461
+ const index = initialOffset + relativeIndex;
1462
+ const base = seedBooks[index % seedBooks.length];
1463
+ generated.push({
1464
+ _id: index + 1,
1465
+ title: base.title + " #" + (index + 1),
1466
+ author: base.author,
1467
+ year: base.year + (index % 5),
1468
+ genre: base.genre,
1469
+ pages: base.pages + (index % 20),
1470
+ isbn: base.isbn + "-" + (index + 1),
1471
+ copyNumber: index + 1
1472
+ });
1473
+ }
1474
+ return generated;
1475
+ }
1476
+
1477
+ const documents =
1478
+ seedMode === "books-demo"
1479
+ ? buildBooksDataset(currentBatchSize, batchStartIndex)
1480
+ : buildSampleDocuments(shardKeyField, currentBatchSize, batchStartIndex);
1481
+
1482
+ const insertResult = database.getCollection(collectionName).insertMany(documents, { ordered: false });
1483
+ const result = {
1484
+ inserted: insertResult.insertedIds ? Object.keys(insertResult.insertedIds).length : documents.length
1485
+ };
1486
+ `.trim()
1487
+ );
1488
+
1489
+ inserted += batchResult.inserted;
1490
+ console.log(`Inserted ${inserted}/${insertCount} documents...`);
1491
+ }
1492
+
1493
+ console.log();
1494
+ return inserted;
1495
+ }
1496
+
1497
+ function buildSampleDocuments(shardKeyField, insertCount, startIndex = 0) {
1498
+ const documents = [];
1499
+ for (let relativeIndex = 1; relativeIndex <= insertCount; relativeIndex += 1) {
1500
+ const index = startIndex + relativeIndex;
1501
+ documents.push({
1502
+ [shardKeyField]: shardKeyField === "_id" ? index : `${shardKeyField}-${index}`,
1503
+ sampleIndex: index,
1504
+ source: "mongodb-cli-lab",
1505
+ createdAt: new Date().toISOString()
1506
+ });
1507
+ }
1508
+
1509
+ return documents;
1510
+ }
1511
+
1512
+ function buildBooksDemoDocuments() {
1513
+ return [
1514
+ { title: "Clean Code", author: "Robert C. Martin", year: 2008, genre: "software", pages: 464, isbn: "9780132350884" },
1515
+ { title: "Designing Data-Intensive Applications", author: "Martin Kleppmann", year: 2017, genre: "databases", pages: 616, isbn: "9781449373320" },
1516
+ { title: "Refactoring", author: "Martin Fowler", year: 1999, genre: "software", pages: 448, isbn: "9780201485677" },
1517
+ { title: "MongoDB: The Definitive Guide", author: "Kristina Chodorow", year: 2013, genre: "databases", pages: 432, isbn: "9781449344689" },
1518
+ { title: "Patterns of Enterprise Application Architecture", author: "Martin Fowler", year: 2002, genre: "architecture", pages: 560, isbn: "9780321127426" },
1519
+ { title: "Release It!", author: "Michael T. Nygard", year: 2007, genre: "operations", pages: 368, isbn: "9780978739218" },
1520
+ { title: "The Pragmatic Programmer", author: "Andrew Hunt", year: 1999, genre: "software", pages: 352, isbn: "9780201616224" },
1521
+ { title: "Building Microservices", author: "Sam Newman", year: 2015, genre: "architecture", pages: 280, isbn: "9781491950357" },
1522
+ { title: "Effective Java", author: "Joshua Bloch", year: 2018, genre: "software", pages: 416, isbn: "9780134685991" },
1523
+ { title: "Site Reliability Engineering", author: "Betsy Beyer", year: 2016, genre: "operations", pages: 552, isbn: "9781491929124" }
1524
+ ];
1525
+ }
1526
+
1527
+ function buildBooksDemoDataset(insertCount, startIndex = 0) {
1528
+ const seedBooks = buildBooksDemoDocuments();
1529
+ const documents = [];
1530
+
1531
+ for (let relativeIndex = 0; relativeIndex < insertCount; relativeIndex += 1) {
1532
+ const index = startIndex + relativeIndex;
1533
+ const base = seedBooks[index % seedBooks.length];
1534
+ documents.push({
1535
+ _id: index + 1,
1536
+ title: `${base.title} #${index + 1}`,
1537
+ author: base.author,
1538
+ year: base.year + (index % 5),
1539
+ genre: base.genre,
1540
+ pages: base.pages + (index % 20),
1541
+ isbn: `${base.isbn}-${index + 1}`,
1542
+ copyNumber: index + 1
1543
+ });
1544
+ }
1545
+
1546
+ return documents;
1547
+ }
1548
+
1549
+ function getCollectionDistribution(state, databaseName, collectionName) {
1550
+ return runMongoJson(
1551
+ state,
1552
+ state.topology.mongos.serviceName,
1553
+ `
1554
+ const databaseName = ${JSON.stringify(databaseName)};
1555
+ const collectionName = ${JSON.stringify(collectionName)};
1556
+ const namespace = databaseName + "." + collectionName;
1557
+ const stats = db.getSiblingDB(databaseName).runCommand({ collStats: collectionName });
1558
+
1559
+ const shardStats = Object.entries(stats.shards || {}).map(([shardName, shardInfo]) => ({
1560
+ shard: shardName,
1561
+ count: shardInfo.count ?? 0,
1562
+ size: shardInfo.size ?? 0,
1563
+ storageSize: shardInfo.storageSize ?? 0
1564
+ }));
1565
+
1566
+ const chunkInfo = db
1567
+ .getSiblingDB("config")
1568
+ .chunks
1569
+ .aggregate([
1570
+ { $match: { ns: namespace } },
1571
+ { $group: { _id: "$shard", chunks: { $sum: 1 } } },
1572
+ { $sort: { _id: 1 } }
1573
+ ])
1574
+ .toArray()
1575
+ .map((entry) => ({ shard: entry._id, chunks: entry.chunks }));
1576
+
1577
+ const chunkSamples = db
1578
+ .getSiblingDB("config")
1579
+ .chunks
1580
+ .find(
1581
+ { ns: namespace },
1582
+ { shard: 1, min: 1, max: 1 }
1583
+ )
1584
+ .limit(12)
1585
+ .toArray()
1586
+ .map((chunk) => ({
1587
+ shard: chunk.shard,
1588
+ min: chunk.min,
1589
+ max: chunk.max
1590
+ }));
1591
+
1592
+ const result = {
1593
+ namespace,
1594
+ count: stats.count ?? 0,
1595
+ shardStats,
1596
+ chunkInfo,
1597
+ chunkSamples
1598
+ };
1599
+ `.trim()
1600
+ );
1601
+ }
1602
+
1603
+ function buildDistributionInsight(distribution) {
1604
+ if (!distribution.shardStats.length) {
1605
+ return "No shard-level distribution is available yet.";
1606
+ }
1607
+
1608
+ const sorted = [...distribution.shardStats].sort((left, right) => right.count - left.count);
1609
+ const top = sorted[0];
1610
+ const total = distribution.count || 0;
1611
+ const topRatio = total > 0 ? top.count / total : 0;
1612
+ const activeShards = sorted.filter((entry) => entry.count > 0).length;
1613
+
1614
+ if (activeShards <= 1 && total > 0) {
1615
+ return [
1616
+ `All documents are currently on ${top.shard}.`,
1617
+ "This usually means the shard key is concentrating values into a small number of chunks, or the balancer has not had enough time/work to redistribute data yet."
1618
+ ].join(" ");
1619
+ }
1620
+
1621
+ if (topRatio >= 0.75) {
1622
+ return [
1623
+ `${top.shard} currently holds most of the documents (${Math.round(topRatio * 100)}%).`,
1624
+ "This is a useful sign that the chosen shard key may not be spreading values evenly."
1625
+ ].join(" ");
1626
+ }
1627
+
1628
+ return "Documents are distributed across multiple shards. Balance is influenced by shard key choice, chunk splits, and balancer timing.";
1629
+ }
1630
+
1631
+ function printCollectionDistribution(distribution) {
1632
+ console.log("Distribution\n");
1633
+ console.log(`Namespace: ${distribution.namespace}`);
1634
+ console.log(`Total documents: ${distribution.count}`);
1635
+
1636
+ if (!distribution.shardStats.length) {
1637
+ console.log("No per-shard stats available yet.\n");
1638
+ return;
1639
+ }
1640
+
1641
+ for (const shard of distribution.shardStats) {
1642
+ const chunk = distribution.chunkInfo.find((entry) => entry.shard === shard.shard);
1643
+ console.log(
1644
+ `- ${shard.shard} | documents: ${shard.count} | chunks: ${chunk?.chunks ?? 0}`
1645
+ );
1646
+ }
1647
+
1648
+ if (distribution.chunkSamples?.length) {
1649
+ console.log("\nChunk samples");
1650
+ for (const chunk of distribution.chunkSamples) {
1651
+ console.log(
1652
+ `- ${chunk.shard} | min: ${JSON.stringify(chunk.min)} | max: ${JSON.stringify(chunk.max)}`
1653
+ );
1654
+ }
1655
+ }
1656
+
1657
+ console.log(`\nInsight: ${buildDistributionInsight(distribution)}`);
1658
+ console.log(
1659
+ "\nNote: distribution is not guaranteed to be perfectly even. It depends on the shard key, value spread, chunk splits, and balancer timing.\n"
1660
+ );
1661
+ }
1662
+
1663
+ function getShardMemberServiceName(state, shardReplicaSet) {
1664
+ const shard = state.topology.shards.find((entry) => entry.replicaSet === shardReplicaSet);
1665
+ return shard?.members[0]?.serviceName ?? null;
1666
+ }
1667
+
1668
+ function getAvailableShardMemberServiceName(state, shardReplicaSet) {
1669
+ const shard = state.topology.shards.find((entry) => entry.replicaSet === shardReplicaSet);
1670
+ if (!shard) {
1671
+ return null;
1672
+ }
1673
+
1674
+ const containerMap = buildContainerMap(getComposeContainers(state));
1675
+ const runningMember = shard.members.find(
1676
+ (member) => containerMap.get(member.serviceName)?.State === "running"
1677
+ );
1678
+
1679
+ return runningMember?.serviceName ?? shard.members[0]?.serviceName ?? null;
1680
+ }
1681
+
1682
+ function inspectShardCollection(state, databaseName, collectionName, shardReplicaSet) {
1683
+ const serviceName = getAvailableShardMemberServiceName(state, shardReplicaSet);
1684
+ if (!serviceName) {
1685
+ throw new Error(`Could not find a container for shard '${shardReplicaSet}'.`);
1686
+ }
1687
+
1688
+ return runMongoJson(
1689
+ state,
1690
+ serviceName,
1691
+ `
1692
+ const databaseName = ${JSON.stringify(databaseName)};
1693
+ const collectionName = ${JSON.stringify(collectionName)};
1694
+ const database = db.getSiblingDB(databaseName);
1695
+ const collection = database.getCollection(collectionName);
1696
+ let count = 0;
1697
+ let sampleDocuments = [];
1698
+ let indexes = [];
1699
+
1700
+ try {
1701
+ count = collection.countDocuments({});
1702
+ sampleDocuments = collection.find({}).limit(5).toArray();
1703
+ indexes = collection.getIndexes().map((index) => ({ name: index.name, key: index.key }));
1704
+ } catch (error) {
1705
+ if (error.codeName !== "NamespaceNotFound" && !String(error.message || "").includes("ns does not exist")) {
1706
+ throw error;
1707
+ }
1708
+ }
1709
+
1710
+ const result = {
1711
+ shard: ${JSON.stringify(shardReplicaSet)},
1712
+ databaseName,
1713
+ collectionName,
1714
+ count,
1715
+ sampleDocuments,
1716
+ indexes
1717
+ };
1718
+ `.trim()
1719
+ );
1720
+ }
1721
+
1722
+ function printShardInspection(inspection) {
1723
+ console.log("\nShard inspection\n");
1724
+ console.log(`Shard: ${inspection.shard}`);
1725
+ console.log(`Namespace: ${inspection.databaseName}.${inspection.collectionName}`);
1726
+ console.log(`Documents visible on this shard primary: ${inspection.count}`);
1727
+ console.log(`Indexes: ${JSON.stringify(inspection.indexes)}\n`);
1728
+
1729
+ if (!inspection.sampleDocuments.length) {
1730
+ console.log("No documents found on this shard for the selected collection.\n");
1731
+ return;
1732
+ }
1733
+
1734
+ console.log("Sample documents on this shard:\n");
1735
+ for (const document of inspection.sampleDocuments) {
1736
+ console.log(JSON.stringify(document, null, 2));
1737
+ }
1738
+ console.log();
1739
+ }
1740
+
1741
+ function getShardDataPresence(state, databaseName, collectionName) {
1742
+ return state.topology.shards.map((shard) => {
1743
+ try {
1744
+ const inspection = inspectShardCollection(
1745
+ state,
1746
+ databaseName,
1747
+ collectionName,
1748
+ shard.replicaSet
1749
+ );
1750
+
1751
+ return {
1752
+ shard: shard.replicaSet,
1753
+ count: inspection.count,
1754
+ available: true
1755
+ };
1756
+ } catch (error) {
1757
+ return {
1758
+ shard: shard.replicaSet,
1759
+ count: 0,
1760
+ available: false,
1761
+ error: error.message
1762
+ };
1763
+ }
1764
+ });
1765
+ }
1766
+
1767
+ function printShardDataPresence(databaseName, collectionName, shardPresence) {
1768
+ console.log("\nShard data presence\n");
1769
+ console.log(`Namespace: ${databaseName}.${collectionName}\n`);
1770
+
1771
+ for (const entry of shardPresence) {
1772
+ if (entry.available === false) {
1773
+ console.log(`- ${entry.shard} | unavailable | ${entry.error}`);
1774
+ continue;
1775
+ }
1776
+
1777
+ const status = entry.count > 0 ? "has data" : "no data";
1778
+ console.log(`- ${entry.shard} | ${status} | documents: ${entry.count}`);
1779
+ }
1780
+
1781
+ const availableShards = shardPresence.filter((entry) => entry.available !== false);
1782
+ const shardsWithData = availableShards.filter((entry) => entry.count > 0).length;
1783
+ const totalShards = availableShards.length;
1784
+
1785
+ if (!availableShards.length) {
1786
+ console.log("\nNo shard members are currently available for inspection.\n");
1787
+ return;
1788
+ }
1789
+
1790
+ if (shardsWithData === 0) {
1791
+ console.log("\nNo shard currently reports local documents for this collection.\n");
1792
+ return;
1793
+ }
1794
+
1795
+ if (shardsWithData === 1) {
1796
+ console.log(
1797
+ "\nOnly one shard currently has data. This usually means the collection has not balanced yet, or the shard key is concentrating writes into one shard.\n"
1798
+ );
1799
+ return;
1800
+ }
1801
+
1802
+ if (shardsWithData < totalShards) {
1803
+ console.log(
1804
+ "\nSome shards have data and others do not. Distribution has started, but it is not spread across the whole cluster yet.\n"
1805
+ );
1806
+ return;
1807
+ }
1808
+
1809
+ console.log("\nAll shards currently have at least some data for this collection.\n");
1810
+ }
1811
+
1812
+ async function promptExistingCollection(overview) {
1813
+ if (!overview.collections.length) {
1814
+ console.log("\nNo user collections found in the cluster.\n");
1815
+ return null;
1816
+ }
1817
+
1818
+ const { namespace } = await inquirer.prompt([
1819
+ {
1820
+ type: "list",
1821
+ name: "namespace",
1822
+ message: "Step 1: Choose a collection to inspect",
1823
+ choices: [
1824
+ ...overview.collections.map((collection) => ({
1825
+ name: `${collection.namespace}${collection.sharded ? ` | ${JSON.stringify(collection.shardKey)}` : ""}`,
1826
+ value: collection.namespace
1827
+ })),
1828
+ { name: "Back", value: "back" }
1829
+ ]
1830
+ }
1831
+ ]);
1832
+
1833
+ if (namespace === "back") {
1834
+ return null;
1835
+ }
1836
+
1837
+ const [databaseName, collectionName] = namespace.split(".");
1838
+ return { databaseName, collectionName, namespace };
1839
+ }
1840
+
1841
+ async function inspectCollectionOnShardFlow(state, overview, target = null) {
1842
+ const selectedTarget = target ?? (await promptExistingCollection(overview));
1843
+ const collectionTarget = selectedTarget;
1844
+ if (!collectionTarget) {
1845
+ return;
1846
+ }
1847
+
1848
+ const distribution = getCollectionDistribution(
1849
+ state,
1850
+ collectionTarget.databaseName,
1851
+ collectionTarget.collectionName
1852
+ );
1853
+ printCollectionDistribution(distribution);
1854
+
1855
+ if (!distribution.shardStats.length) {
1856
+ console.log("No shard distribution is available for this collection yet.\n");
1857
+ return;
1858
+ }
1859
+
1860
+ const { shardReplicaSet } = await inquirer.prompt([
1861
+ {
1862
+ type: "list",
1863
+ name: "shardReplicaSet",
1864
+ message: "Step 2: Choose which shard you want to inspect directly",
1865
+ choices: [
1866
+ ...distribution.shardStats.map((entry) => ({
1867
+ name: `${entry.shard} | documents: ${entry.count}`,
1868
+ value: entry.shard
1869
+ })),
1870
+ { name: "Back", value: "back" }
1871
+ ]
1872
+ }
1873
+ ]);
1874
+
1875
+ if (shardReplicaSet === "back") {
1876
+ return;
1877
+ }
1878
+
1879
+ const inspection = inspectShardCollection(
1880
+ state,
1881
+ collectionTarget.databaseName,
1882
+ collectionTarget.collectionName,
1883
+ shardReplicaSet
1884
+ );
1885
+ printShardInspection(inspection);
1886
+ }
1887
+
1888
+ async function runGuidedBooksDemo(state) {
1889
+ printBooksDemoIntroduction();
1890
+
1891
+ const overview = getClusterOverview(state);
1892
+ const existingDemo = overview.collections.find(
1893
+ (collection) => collection.namespace === "library.books"
1894
+ );
1895
+ let resetCollection = false;
1896
+ let currentDocumentCount = 0;
1897
+
1898
+ if (existingDemo) {
1899
+ const { demoAction } = await inquirer.prompt([
1900
+ {
1901
+ type: "list",
1902
+ name: "demoAction",
1903
+ message: "Step 0: A guided demo collection already exists at library.books. What should happen next?",
1904
+ choices: [
1905
+ { name: "Reset and run the demo again", value: "reset" },
1906
+ { name: "Reuse existing collection and add more data", value: "append" },
1907
+ { name: "Back", value: "back" }
1908
+ ],
1909
+ default: "reset"
1910
+ }
1911
+ ]);
1912
+
1913
+ if (demoAction === "back") {
1914
+ return;
1915
+ }
1916
+
1917
+ resetCollection = demoAction === "reset";
1918
+ if (!resetCollection) {
1919
+ currentDocumentCount = getCollectionDocumentCount(state, "library", "books");
1920
+ }
1921
+ }
1922
+
1923
+ let shardStrategy;
1924
+ let insertCount;
1925
+ while (true) {
1926
+ const answers = await inquirer.prompt([
1927
+ {
1928
+ type: "list",
1929
+ name: "shardStrategy",
1930
+ message: "Step 1: Choose a shard key strategy for library.books",
1931
+ choices: [
1932
+ { name: "year (range, easy to understand, but low cardinality)", value: { field: "year", mode: "range" } },
1933
+ { name: "author (range, better than year here, but still limited)", value: { field: "author", mode: "range" } },
1934
+ { name: "_id (range, better baseline)", value: { field: "_id", mode: "range" } },
1935
+ { name: "_id (hashed, best for stronger distribution in this demo)", value: { field: "_id", mode: "hashed" } },
1936
+ { name: "Back", value: "back" }
1937
+ ],
1938
+ default: { field: "year", mode: "range" }
1939
+ },
1940
+ {
1941
+ type: "list",
1942
+ name: "insertCountChoice",
1943
+ message: "Step 2: Choose how much demo data should be inserted",
1944
+ choices: [
1945
+ { name: "10 documents (quick demo)", value: 10 },
1946
+ { name: "100 documents (better distribution view)", value: 100 },
1947
+ { name: "500 documents (stronger distribution demo)", value: 500 },
1948
+ { name: "1,000,000 documents (large stress demo)", value: 1000000 },
1949
+ { name: "Custom", value: "custom" },
1950
+ { name: "Back", value: "back" }
1951
+ ],
1952
+ default: 100
1953
+ }
1954
+ ]);
1955
+
1956
+ if (answers.shardStrategy === "back" || answers.insertCountChoice === "back") {
1957
+ return;
1958
+ }
1959
+
1960
+ shardStrategy = answers.shardStrategy;
1961
+ if (answers.insertCountChoice !== "custom") {
1962
+ insertCount = answers.insertCountChoice;
1963
+ break;
1964
+ }
1965
+
1966
+ const answer = await inquirer.prompt([
1967
+ {
1968
+ type: "input",
1969
+ name: "insertCount",
1970
+ message: "Enter how many books should be inserted:",
1971
+ default: 1000,
1972
+ validate: (value) =>
1973
+ value.trim().toLowerCase() === "back" ||
1974
+ (Number.isInteger(Number(value)) && Number(value) >= 1)
1975
+ ? true
1976
+ : "Insert count must be an integer greater than 0."
1977
+ }
1978
+ ]);
1979
+ if (answer.insertCount.trim().toLowerCase() === "back") {
1980
+ return;
1981
+ }
1982
+ insertCount = Number(answer.insertCount);
1983
+ break;
1984
+ }
1985
+
1986
+ if (existingDemo && !resetCollection) {
1987
+ const requestedShardKey = formatShardKey(shardStrategy.field, shardStrategy.mode);
1988
+ const currentShardKey = JSON.stringify(existingDemo.shardKey);
1989
+
1990
+ if (currentShardKey !== requestedShardKey) {
1991
+ console.log("\nShard key change detected");
1992
+ console.log(`Current shard key: ${currentShardKey}`);
1993
+ console.log(`Requested shard key: ${requestedShardKey}`);
1994
+ console.log("Action: reshardCollection will run before inserting more data.\n");
1995
+ } else {
1996
+ console.log("\nThe requested shard key matches the current shard key. Existing sharding will be reused.\n");
1997
+ }
1998
+ }
1999
+
2000
+ printInsertPlan("library.books", insertCount);
2001
+ const result = shardCollection(state, {
2002
+ databaseName: "library",
2003
+ collectionName: "books",
2004
+ shardKeyField: shardStrategy.field,
2005
+ shardKeyMode: shardStrategy.mode,
2006
+ documents: [],
2007
+ resetCollection,
2008
+ skipInsert: true
2009
+ });
2010
+ const inserted = insertDocumentsInBatches(state, {
2011
+ databaseName: "library",
2012
+ collectionName: "books",
2013
+ shardKeyField: shardStrategy.field,
2014
+ insertCount,
2015
+ startIndex: currentDocumentCount,
2016
+ seedMode: "books-demo"
2017
+ });
2018
+ const distribution = getCollectionDistribution(state, "library", "books");
2019
+
2020
+ console.log("\nGuided demo completed\n");
2021
+ console.log(`Namespace: ${result.namespace}`);
2022
+ console.log(`Previous shard key: ${JSON.stringify(result.previousShardKey)}`);
2023
+ console.log(`Shard key: ${JSON.stringify(result.shardKey)}`);
2024
+ console.log(`Action: ${result.actionTaken}`);
2025
+ console.log(`Documents inserted: ${inserted}`);
2026
+ console.log("Insert completed.");
2027
+ console.log(`Shard result: ${JSON.stringify(result.shardResult)}\n`);
2028
+ printCollectionDistribution(distribution);
2029
+ console.log("Tip: use 'Inspect collection distribution' to see which shard has data and inspect one shard directly.\n");
2030
+ }
2031
+
2032
+ async function runCustomCollectionFlow(state, overview, mode = "custom") {
2033
+ const target = await promptCollectionTarget(overview);
2034
+ if (!target) {
2035
+ return;
2036
+ }
2037
+
2038
+ printDocsLink("Shard keys", DOCS.shardKey);
2039
+ printDocsLink("Hashed sharding", DOCS.hashedSharding);
2040
+ console.log();
2041
+
2042
+ let currentDocumentCount = 0;
2043
+ let resetCollection = false;
2044
+
2045
+ if (target.alreadySharded) {
2046
+ console.log(
2047
+ `\n${target.databaseName}.${target.collectionName} is already sharded with ${JSON.stringify(target.currentShardKey)}.\n`
2048
+ );
2049
+
2050
+ const { existingCollectionAction } = await inquirer.prompt([
2051
+ {
2052
+ type: "list",
2053
+ name: "existingCollectionAction",
2054
+ message: "Choose how to work with the existing collection",
2055
+ choices: [
2056
+ { name: "Append more documents", value: "append" },
2057
+ { name: "Reset the collection and recreate it", value: "reset" },
2058
+ { name: "Only change the shard key if needed", value: "reshard" },
2059
+ { name: "Back", value: "back" }
2060
+ ],
2061
+ default: "append"
2062
+ }
2063
+ ]);
2064
+
2065
+ if (existingCollectionAction === "back") {
2066
+ return;
2067
+ }
2068
+
2069
+ resetCollection = existingCollectionAction === "reset";
2070
+ if (!resetCollection) {
2071
+ currentDocumentCount = getCollectionDocumentCount(
2072
+ state,
2073
+ target.databaseName,
2074
+ target.collectionName
2075
+ );
2076
+ }
2077
+ }
2078
+
2079
+ let answers;
2080
+ let insertCount;
2081
+ while (true) {
2082
+ answers = await inquirer.prompt([
2083
+ {
2084
+ type: "list",
2085
+ name: "continue",
2086
+ message: "Continue to shard key and sample insert options?",
2087
+ choices: [
2088
+ { name: "Continue", value: "continue" },
2089
+ { name: "Back", value: "back" }
2090
+ ],
2091
+ default: "continue"
2092
+ },
2093
+ {
2094
+ type: "input",
2095
+ name: "shardKeyField",
2096
+ message: "Shard key field:",
2097
+ default: "_id",
2098
+ when: (answers) => answers.continue === "continue",
2099
+ validate: (value) =>
2100
+ value.trim().toLowerCase() === "back" || value.trim() ? true : "Shard key field cannot be empty."
2101
+ },
2102
+ {
2103
+ type: "list",
2104
+ name: "shardKeyMode",
2105
+ message: "Shard key strategy:",
2106
+ choices: [
2107
+ { name: "Range ({ field: 1 })", value: "range" },
2108
+ { name: "Hashed ({ field: \"hashed\" })", value: "hashed" },
2109
+ { name: "Back", value: "back" }
2110
+ ],
2111
+ default: "range",
2112
+ when: (answers) => answers.continue === "continue"
2113
+ },
2114
+ {
2115
+ type: "list",
2116
+ name: "insertCountChoice",
2117
+ message: "How many demo documents should be inserted?",
2118
+ choices: [
2119
+ { name: "0 documents", value: 0 },
2120
+ { name: "20 documents", value: 20 },
2121
+ { name: "100 documents", value: 100 },
2122
+ { name: "1000 documents", value: 1000 },
2123
+ { name: "Custom", value: "custom" },
2124
+ { name: "Back", value: "back" }
2125
+ ],
2126
+ default: mode === "demo" ? 20 : 0,
2127
+ when: (answers) => answers.continue === "continue",
2128
+ }
2129
+ ]);
2130
+
2131
+ if (
2132
+ answers.continue === "back" ||
2133
+ answers.shardKeyField?.trim?.().toLowerCase() === "back" ||
2134
+ answers.shardKeyMode === "back" ||
2135
+ answers.insertCountChoice === "back"
2136
+ ) {
2137
+ return;
2138
+ }
2139
+
2140
+ if (answers.insertCountChoice !== "custom") {
2141
+ insertCount = answers.insertCountChoice;
2142
+ break;
2143
+ }
2144
+
2145
+ const customAnswer = await inquirer.prompt([
2146
+ {
2147
+ type: "input",
2148
+ name: "customInsertCount",
2149
+ message: "Enter how many demo documents should be inserted:",
2150
+ default: 5000,
2151
+ validate: (value) =>
2152
+ value.trim().toLowerCase() === "back" ||
2153
+ (Number.isInteger(Number(value)) && Number(value) >= 0)
2154
+ ? true
2155
+ : "Insert count must be an integer greater than or equal to 0."
2156
+ }
2157
+ ]);
2158
+
2159
+ if (customAnswer.customInsertCount.trim().toLowerCase() === "back") {
2160
+ return;
2161
+ }
2162
+
2163
+ insertCount = Number(customAnswer.customInsertCount);
2164
+ break;
2165
+ }
2166
+
2167
+ if (target.alreadySharded) {
2168
+ const requestedShardKey = formatShardKey(
2169
+ answers.shardKeyField.trim(),
2170
+ answers.shardKeyMode
2171
+ );
2172
+ const currentShardKey = JSON.stringify(target.currentShardKey);
2173
+
2174
+ if (currentShardKey !== requestedShardKey) {
2175
+ console.log("\nShard key change detected");
2176
+ console.log(`Current shard key: ${currentShardKey}`);
2177
+ console.log(`Requested shard key: ${requestedShardKey}`);
2178
+ console.log("Action: reshardCollection will run before inserting more data.\n");
2179
+ } else {
2180
+ console.log("\nThe requested shard key matches the current shard key. Existing sharding will be reused.\n");
2181
+ }
2182
+ }
2183
+
2184
+ printInsertPlan(`${target.databaseName}.${target.collectionName}`, insertCount);
2185
+ const result = shardCollection(state, {
2186
+ databaseName: target.databaseName,
2187
+ collectionName: target.collectionName,
2188
+ shardKeyField: answers.shardKeyField.trim(),
2189
+ shardKeyMode: answers.shardKeyMode,
2190
+ documents: [],
2191
+ resetCollection,
2192
+ skipInsert: true
2193
+ });
2194
+ const inserted = insertDocumentsInBatches(state, {
2195
+ databaseName: target.databaseName,
2196
+ collectionName: target.collectionName,
2197
+ shardKeyField: answers.shardKeyField.trim(),
2198
+ insertCount,
2199
+ startIndex: currentDocumentCount,
2200
+ seedMode: "sample-generated"
2201
+ });
2202
+
2203
+ console.log("\nCollection updated\n");
2204
+ console.log(`Namespace: ${result.namespace}`);
2205
+ console.log(`Previous shard key: ${JSON.stringify(result.previousShardKey)}`);
2206
+ console.log(`Shard key: ${JSON.stringify(result.shardKey)}`);
2207
+ console.log(`Action: ${result.actionTaken}`);
2208
+ console.log(`Documents inserted: ${inserted}`);
2209
+ console.log("Insert completed.");
2210
+ console.log(`Shard result: ${JSON.stringify(result.shardResult)}\n`);
2211
+ }
2212
+
2213
+ async function interactiveShardingMenu() {
2214
+ const state = await loadState();
2215
+ if (!state) {
2216
+ console.log("\nNo cluster has been configured yet.\n");
2217
+ return;
2218
+ }
2219
+
2220
+ ensureMongosRunning(state);
2221
+
2222
+ let exitMenu = false;
2223
+ while (!exitMenu) {
2224
+ const overview = getClusterOverview(state);
2225
+ const { action } = await inquirer.prompt([
2226
+ {
2227
+ type: "list",
2228
+ name: "action",
2229
+ message: "Step 3: Learn and experiment with sharded collections",
2230
+ choices: [
2231
+ { name: "1. List databases and collections (see what already exists)", value: "list" },
2232
+ { name: "2. Guided demo: library.books (create sample data and shard it)", value: "guided-books" },
2233
+ { name: "3. Custom sharded collection (define your own database and shard key)", value: "custom" },
2234
+ { name: "4. Inspect collection distribution (which shard has data?)", value: "inspect-shard" },
2235
+ { name: "5. Back", value: "back" }
2236
+ ],
2237
+ default: "list"
2238
+ }
2239
+ ]);
2240
+
2241
+ if (action === "list") {
2242
+ printCollectionOverview(overview);
2243
+ continue;
2244
+ }
2245
+
2246
+ if (action === "guided-books") {
2247
+ await runGuidedBooksDemo(state);
2248
+ continue;
2249
+ }
2250
+
2251
+ if (action === "custom") {
2252
+ await runCustomCollectionFlow(state, overview);
2253
+ continue;
2254
+ }
2255
+
2256
+ if (action === "inspect-shard") {
2257
+ const target = await promptExistingCollection(overview);
2258
+ if (!target) {
2259
+ continue;
2260
+ }
2261
+
2262
+ const shardPresence = getShardDataPresence(
2263
+ state,
2264
+ target.databaseName,
2265
+ target.collectionName
2266
+ );
2267
+ printShardDataPresence(target.databaseName, target.collectionName, shardPresence);
2268
+ await inspectCollectionOnShardFlow(state, overview, target);
2269
+ continue;
2270
+ }
2271
+
2272
+ exitMenu = true;
2273
+ }
2274
+ }
2275
+
2276
+ async function bringUpCluster(state) {
2277
+ const totalSteps = 5;
2278
+ const configServices = state.topology.configServers.map((member) => member.serviceName);
2279
+ const shardServices = state.topology.shards.flatMap((shard) =>
2280
+ shard.members.map((member) => member.serviceName)
2281
+ );
2282
+
2283
+ console.log(
2284
+ "\nStarting cluster setup. If a step takes too long, you can interrupt with Ctrl+C and run 'up' again. The process is designed to retry safely.\n"
2285
+ );
2286
+
2287
+ printStep(
2288
+ 1,
2289
+ totalSteps,
2290
+ "Prepare local directories",
2291
+ "Creating the folders used by config servers, shard members, and logs."
2292
+ );
2293
+ await ensureDirectories(state.topology, state.config.storagePath);
2294
+
2295
+ printStep(
2296
+ 2,
2297
+ totalSteps,
2298
+ "Start MongoDB containers",
2299
+ "Starting config server members and shard replica set members with Docker."
2300
+ );
2301
+ runCompose(state, ["up", "-d", ...configServices, ...shardServices]);
2302
+
2303
+ printStep(
2304
+ 3,
2305
+ totalSteps,
2306
+ "Initialize config server replica set",
2307
+ "Waiting for config server members and creating the replica set that stores cluster metadata."
2308
+ );
2309
+ console.log(`Initializing config replica set '${state.config.configServerReplicaSet}'...`);
2310
+ waitForServices(
2311
+ state,
2312
+ state.topology.configServers.map((member) => member.serviceName)
2313
+ );
2314
+ runMongoScript(
2315
+ state,
2316
+ state.topology.configServers[0].serviceName,
2317
+ buildReplicaInitScript(
2318
+ buildReplicaSetConfig(state.config.configServerReplicaSet, state.topology.configServers, {
2319
+ configsvr: true
2320
+ })
2321
+ )
2322
+ );
2323
+
2324
+ printStep(
2325
+ 4,
2326
+ totalSteps,
2327
+ "Initialize shard replica sets",
2328
+ "Waiting for shard members, then electing a primary in each shard replica set."
2329
+ );
2330
+ for (const shard of state.topology.shards) {
2331
+ console.log(`Initializing shard replica set '${shard.replicaSet}'...`);
2332
+ waitForServices(
2333
+ state,
2334
+ shard.members.map((member) => member.serviceName)
2335
+ );
2336
+ runMongoScript(
2337
+ state,
2338
+ shard.members[0].serviceName,
2339
+ buildReplicaInitScript(buildReplicaSetConfig(shard.replicaSet, shard.members))
2340
+ );
2341
+ }
2342
+
2343
+ printStep(
2344
+ 5,
2345
+ totalSteps,
2346
+ "Start mongos and register shards",
2347
+ "Starting the router, then attaching each shard replica set to the cluster."
2348
+ );
2349
+ console.log(`Starting mongos on port ${state.config.mongosPort}...`);
2350
+ runCompose(state, ["up", "-d", state.topology.mongos.serviceName]);
2351
+ waitForMongo(state, state.topology.mongos.serviceName);
2352
+ runMongoScript(state, state.topology.mongos.serviceName, buildAddShardsScript(state.topology));
2353
+ }
2354
+
2355
+ function sanitizeProjectName(value) {
2356
+ return value.replace(/[^a-zA-Z0-9_-]/g, "-");
2357
+ }
2358
+
2359
+ async function confirmAction(message, defaultValue = false) {
2360
+ const { confirmed } = await inquirer.prompt([
2361
+ {
2362
+ type: "list",
2363
+ name: "confirmed",
2364
+ message,
2365
+ choices: [
2366
+ { name: defaultValue ? "Continue" : "Confirm", value: true },
2367
+ { name: "Back", value: false }
2368
+ ],
2369
+ default: true
2370
+ }
2371
+ ]);
2372
+
2373
+ return confirmed;
2374
+ }
2375
+
2376
+ async function promptConfigReuse(existingState) {
2377
+ const { action } = await inquirer.prompt([
2378
+ {
2379
+ type: "list",
2380
+ name: "action",
2381
+ message: `Cluster config already exists at ${existingState.config.storagePath}\nChoose how you want to continue`,
2382
+ choices: [
2383
+ { name: "Reuse existing config", value: "reuse" },
2384
+ { name: "Create a new config", value: "replace" },
2385
+ { name: "Cancel", value: "cancel" }
2386
+ ],
2387
+ default: "reuse"
2388
+ }
2389
+ ]);
2390
+
2391
+ return action;
2392
+ }
2393
+
2394
+ async function createStateFromAnswers() {
2395
+ const config = await promptClusterOptions();
2396
+ if (!config) {
2397
+ return null;
2398
+ }
2399
+
2400
+ const confirmed = await confirmAction(
2401
+ [
2402
+ "Create cluster with this topology?",
2403
+ `Shards: ${config.shardCount}`,
2404
+ `Members per shard: ${config.replicaSetMembers}`,
2405
+ `MongoDB version: ${config.mongodbVersion}`,
2406
+ `mongos port: ${config.mongosPort}`,
2407
+ `Storage path: ${config.storagePath}`
2408
+ ].join("\n"),
2409
+ true
2410
+ );
2411
+
2412
+ if (!confirmed) {
2413
+ return null;
2414
+ }
2415
+
2416
+ const topology = buildTopology(config);
2417
+ const files = await writeClusterFiles(config, topology);
2418
+ const state = {
2419
+ projectName: sanitizeProjectName(DEFAULT_PROJECT_NAME),
2420
+ config,
2421
+ topology,
2422
+ ...files
2423
+ };
2424
+
2425
+ await saveState(state);
2426
+ return state;
2427
+ }
2428
+
2429
+ async function requireState() {
2430
+ const state = await loadState();
2431
+
2432
+ if (!state) {
2433
+ throw new Error("No cluster state found. Run 'mongodb-cli-lab up' first.");
2434
+ }
2435
+
2436
+ return state;
2437
+ }
2438
+
2439
+ async function runUp() {
2440
+ ensureDockerAvailable();
2441
+
2442
+ let state = await loadState();
2443
+ if (state) {
2444
+ const action = await promptConfigReuse(state);
2445
+
2446
+ if (action === "cancel") {
2447
+ return;
2448
+ }
2449
+
2450
+ if (action === "replace") {
2451
+ await fs.rm(state.config.storagePath, { recursive: true, force: true });
2452
+ state = null;
2453
+ }
2454
+ }
2455
+
2456
+ if (!state) {
2457
+ state = await createStateFromAnswers();
2458
+ if (!state) {
2459
+ return;
2460
+ }
2461
+ } else {
2462
+ console.log(`\nUsing existing cluster config from ${state.config.storagePath}\n`);
2463
+ }
2464
+
2465
+ await bringUpCluster(state);
2466
+
2467
+ console.log("\nCluster ready\n");
2468
+ printTopologyDiagram(state);
2469
+ console.log("Connection string:");
2470
+ console.log(`mongodb://localhost:${state.config.mongosPort}`);
2471
+ console.log("\nIf initialization is interrupted, rerunning 'up' will retry safely.\n");
2472
+ }
2473
+
2474
+ async function runDown() {
2475
+ const state = await requireState();
2476
+ printStep(1, 1, "Stop cluster", "Stopping the running Docker containers for this lab.");
2477
+ runCompose(state, ["down"]);
2478
+ }
2479
+
2480
+ async function runStatus() {
2481
+ ensureDockerAvailable();
2482
+
2483
+ const state = await loadActiveClusterState();
2484
+ if (!state) {
2485
+ console.log("\nNo running MongoDB CLI Lab cluster was found for this project.\n");
2486
+
2487
+ if (await confirmAction("Do you want to create a new cluster now?", true)) {
2488
+ await runUp();
2489
+ }
2490
+
2491
+ return;
2492
+ }
2493
+
2494
+ const containers = getComposeContainers(state);
2495
+ printClusterSummary(state, containers);
2496
+ printTopologyDiagram(state);
2497
+ printReplicaSetHealth(state, containers);
2498
+ printTopologyDetails(state, containers);
2499
+ printContainersTable(containers);
2500
+ }
2501
+
2502
+ async function runClean() {
2503
+ const state = await requireState();
2504
+
2505
+ printStep(1, 2, "Stop containers", "Stopping and removing containers, networks, and volumes for this lab.");
2506
+ try {
2507
+ runCompose(state, ["down", "--remove-orphans", "-v"]);
2508
+ } catch {
2509
+ // Keep removing files even if Docker cleanup fails.
2510
+ }
2511
+
2512
+ printStep(2, 2, "Delete generated files", "Removing generated configuration, state, and local data directories.");
2513
+ await deleteState(state.config.storagePath);
2514
+ await fs.rm(state.config.storagePath, { recursive: true, force: true });
2515
+ }
2516
+
2517
+ async function showNodeDetails() {
2518
+ ensureDockerAvailable();
2519
+
2520
+ const state = await loadActiveClusterState();
2521
+ if (!state) {
2522
+ console.log("\nNo running MongoDB CLI Lab cluster was found for this project.\n");
2523
+ return;
2524
+ }
2525
+
2526
+ const containers = getComposeContainers(state);
2527
+ printClusterSummary(state, containers);
2528
+ printTopologyDiagram(state);
2529
+ printReplicaSetHealth(state, containers);
2530
+ printTopologyDetails(state, containers);
2531
+ printContainersTable(containers);
2532
+ }
2533
+
2534
+ async function interactiveClusterMenu() {
2535
+ const state = await loadState();
2536
+ if (!state) {
2537
+ console.log("\nNo cluster has been configured yet.\n");
2538
+ return;
2539
+ }
2540
+
2541
+ let exitMenu = false;
2542
+
2543
+ while (!exitMenu) {
2544
+ const containers = getComposeContainers(state);
2545
+ const runningCount = containers.filter((container) => container.State === "running").length;
2546
+
2547
+ const { action } = await inquirer.prompt([
2548
+ {
2549
+ type: "list",
2550
+ name: "action",
2551
+ message: `Step 4: Manage the cluster lifecycle\n${runningCount}/${countExpectedNodes(state)} nodes are currently running`,
2552
+ choices: [
2553
+ { name: "1. Show summary and node list (see the current topology)", value: "show" },
2554
+ { name: "2. Start cluster (bring containers up)", value: "up" },
2555
+ { name: "3. Stop cluster (bring containers down)", value: "down" },
2556
+ { name: "4. Delete cluster and files (remove local lab data)", value: "clean" },
2557
+ { name: "5. Back", value: "back" }
2558
+ ],
2559
+ default: "show"
2560
+ }
2561
+ ]);
2562
+
2563
+ if (action === "show") {
2564
+ await showNodeDetails();
2565
+ continue;
2566
+ }
2567
+
2568
+ if (action === "up") {
2569
+ await runUp();
2570
+ continue;
2571
+ }
2572
+
2573
+ if (action === "down") {
2574
+ if (await confirmAction("Stop all cluster containers?")) {
2575
+ await runDown();
2576
+ }
2577
+ continue;
2578
+ }
2579
+
2580
+ if (action === "clean") {
2581
+ if (await confirmAction("Delete containers, volumes and generated files?")) {
2582
+ await runClean();
2583
+ exitMenu = true;
2584
+ }
2585
+ continue;
2586
+ }
2587
+
2588
+ exitMenu = true;
2589
+ }
2590
+ }
2591
+
2592
+ async function interactiveMainMenu() {
2593
+ ensureDockerAvailable();
2594
+
2595
+ let exitMenu = false;
2596
+
2597
+ while (!exitMenu) {
2598
+ const activeState = await loadActiveClusterState();
2599
+ const savedState = activeState ?? await loadState();
2600
+ const stateLabel = activeState
2601
+ ? `Active cluster: ${activeState.config.shardCount} shard(s), ${activeState.config.replicaSetMembers} member(s) each`
2602
+ : savedState
2603
+ ? "No cluster running right now"
2604
+ : "No cluster configured yet";
2605
+
2606
+ const { action } = await inquirer.prompt([
2607
+ {
2608
+ type: "list",
2609
+ name: "action",
2610
+ message: `MongoDB CLI Lab\n${stateLabel}\nChoose the next step in the lab`,
2611
+ choices: [
2612
+ { name: "1. Create or start cluster (infrastructure setup)", value: "up" },
2613
+ { name: "2. Show cluster status and nodes (understand the topology)", value: "status" },
2614
+ { name: "3. Collections and sharding (learn with data)", value: "collections" },
2615
+ { name: "4. Manage cluster (stop, restart, or delete)", value: "manage" },
2616
+ { name: "5. Exit", value: "exit" }
2617
+ ],
2618
+ default: "up"
2619
+ }
2620
+ ]);
2621
+
2622
+ if (action === "up") {
2623
+ await runUp();
2624
+ continue;
2625
+ }
2626
+
2627
+ if (action === "status") {
2628
+ try {
2629
+ await runStatus();
2630
+ } catch (error) {
2631
+ console.log(`\n${error.message}\n`);
2632
+ }
2633
+ continue;
2634
+ }
2635
+
2636
+ if (action === "collections") {
2637
+ try {
2638
+ await interactiveShardingMenu();
2639
+ } catch (error) {
2640
+ console.log(`\n${error.message}\n`);
2641
+ }
2642
+ continue;
2643
+ }
2644
+
2645
+ if (action === "manage") {
2646
+ try {
2647
+ await interactiveClusterMenu();
2648
+ } catch (error) {
2649
+ console.log(`\n${error.message}\n`);
2650
+ }
2651
+ continue;
2652
+ }
2653
+
2654
+ exitMenu = true;
2655
+ }
2656
+ }
2657
+
2658
+ function handleAction(action) {
2659
+ action().catch((error) => {
2660
+ console.error(`\nError: ${error.message}\n`);
2661
+ process.exitCode = 1;
2662
+ });
2663
+ }
2664
+
2665
+ const program = new Command();
2666
+
2667
+ program
2668
+ .name("mongodb-cli-lab")
2669
+ .description("CLI to spin up a local MongoDB sharded cluster with Docker")
2670
+ .version("0.0.1");
2671
+
2672
+ program
2673
+ .command("up")
2674
+ .description("Create and start a local MongoDB sharded cluster")
2675
+ .action(() => handleAction(runUp));
2676
+
2677
+ program
2678
+ .command("down")
2679
+ .description("Stop the running cluster")
2680
+ .action(() => handleAction(runDown));
2681
+
2682
+ program
2683
+ .command("status")
2684
+ .description("Show cluster status")
2685
+ .action(() => handleAction(runStatus));
2686
+
2687
+ program
2688
+ .command("clean")
2689
+ .description("Remove containers, volumes, and generated files")
2690
+ .action(() => handleAction(runClean));
2691
+
2692
+ program.action(() => handleAction(interactiveMainMenu));
2693
+
2694
+ program.parseAsync(process.argv);